diff options
30 files changed, 1472 insertions, 431 deletions
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 465beb42b..dda090c52 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -3774,11 +3774,13 @@ paths: /api/v1/admin/accounts: get: description: |- + Returned accounts will be ordered alphabetically (a-z) by domain + username. + The next and previous queries can be parsed from the returned Link header. Example: ``` - <https://example.org/api/v1/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" + <https://example.org/api/v1/admin/accounts?limit=80&max_id=example.org%2F%40someone>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=example.org%2F%40someone_else>; rel="prev" ```` operationId: adminAccountsGetV1 parameters: @@ -3847,19 +3849,15 @@ paths: in: query name: staff type: boolean - - description: All results returned will be older than the item with this ID. + - description: max_id in the form `[domain]/@[username]`. All results returned will be later in the alphabet than `[domain]/@[username]`. For example, if max_id = `example.org/@someone` then returned entries might contain `example.org/@someone_else`, `later.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: max_id type: string - - description: All results returned will be newer than the item with this ID. - in: query - name: since_id - type: string - - description: Returns results immediately newer than the item with this ID. + - description: min_id in the form `[domain]/@[username]`. All results returned will be earlier in the alphabet than `[domain]/@[username]`. For example, if min_id = `example.org/@someone` then returned entries might contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: min_id type: string - - default: 100 + - default: 50 description: Maximum number of results to return. in: query maximum: 200 @@ -8463,11 +8461,13 @@ paths: /api/v2/admin/accounts: get: description: |- + Returned accounts will be ordered alphabetically (a-z) by domain + username. + The next and previous queries can be parsed from the returned Link header. Example: ``` - <https://example.org/api/v2/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" + <https://example.org/api/v2/admin/accounts?limit=80&max_id=example.org%2F%40someone>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=example.org%2F%40someone_else>; rel="prev" ```` operationId: adminAccountsGetV2 parameters: @@ -8513,19 +8513,15 @@ paths: in: query name: ip type: string - - description: All results returned will be older than the item with this ID. + - description: max_id in the form `[domain]/@[username]`. All results returned will be later in the alphabet than `[domain]/@[username]`. For example, if max_id = `example.org/@someone` then returned entries might contain `example.org/@someone_else`, `later.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: max_id type: string - - description: All results returned will be newer than the item with this ID. - in: query - name: since_id - type: string - - description: Returns results immediately newer than the item with this ID. + - description: min_id in the form `[domain]/@[username]`. All results returned will be earlier in the alphabet than `[domain]/@[username]`. For example, if min_id = `example.org/@someone` then returned entries might contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. Local account IDs in this form use an empty string for the `[domain]` part, for example local account with username `someone` would be `/@someone`. in: query name: min_id type: string - - default: 100 + - default: 50 description: Maximum number of results to return. in: query maximum: 200 diff --git a/internal/api/client/admin/accountsgetv1.go b/internal/api/client/admin/accountsgetv1.go index 604d74992..f333492de 100644 --- a/internal/api/client/admin/accountsgetv1.go +++ b/internal/api/client/admin/accountsgetv1.go @@ -19,11 +19,13 @@ // // View + page through known accounts according to given filters. // +// Returned accounts will be ordered alphabetically (a-z) by domain + username. +// // The next and previous queries can be parsed from the returned Link header. // Example: // // ``` -// <https://example.org/api/v1/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// <https://example.org/api/v1/admin/accounts?limit=80&max_id=example.org%2F%40someone>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=example.org%2F%40someone_else>; rel="prev" // ```` // // --- @@ -117,23 +119,30 @@ // name: max_id // in: query // type: string -// description: All results returned will be older than the item with this ID. -// - -// name: since_id -// in: query -// type: string -// description: All results returned will be newer than the item with this ID. +// description: >- +// max_id in the form `[domain]/@[username]`. +// All results returned will be later in the alphabet than `[domain]/@[username]`. +// For example, if max_id = `example.org/@someone` then returned entries might +// contain `example.org/@someone_else`, `later.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: min_id // in: query // type: string -// description: Returns results immediately newer than the item with this ID. +// description: >- +// min_id in the form `[domain]/@[username]`. +// All results returned will be earlier in the alphabet than `[domain]/@[username]`. +// For example, if min_id = `example.org/@someone` then returned entries might +// contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: limit // in: query // type: integer // description: Maximum number of results to return. -// default: 100 +// default: 50 // maximum: 200 // minimum: 1 // @@ -200,7 +209,7 @@ func (m *Module) AccountsGETV1Handler(c *gin.Context) { return } - page, errWithCode := paging.ParseIDPage(c, 1, 200, 100) + page, errWithCode := paging.ParseIDPage(c, 1, 200, 50) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/admin/accountsgetv2.go b/internal/api/client/admin/accountsgetv2.go index ca32b9e7f..27024e7a2 100644 --- a/internal/api/client/admin/accountsgetv2.go +++ b/internal/api/client/admin/accountsgetv2.go @@ -19,11 +19,13 @@ // // View + page through known accounts according to given filters. // +// Returned accounts will be ordered alphabetically (a-z) by domain + username. +// // The next and previous queries can be parsed from the returned Link header. // Example: // // ``` -// <https://example.org/api/v2/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// <https://example.org/api/v2/admin/accounts?limit=80&max_id=example.org%2F%40someone>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=example.org%2F%40someone_else>; rel="prev" // ```` // // --- @@ -90,23 +92,30 @@ // name: max_id // in: query // type: string -// description: All results returned will be older than the item with this ID. -// - -// name: since_id -// in: query -// type: string -// description: All results returned will be newer than the item with this ID. +// description: >- +// max_id in the form `[domain]/@[username]`. +// All results returned will be later in the alphabet than `[domain]/@[username]`. +// For example, if max_id = `example.org/@someone` then returned entries might +// contain `example.org/@someone_else`, `later.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: min_id // in: query // type: string -// description: Returns results immediately newer than the item with this ID. +// description: >- +// min_id in the form `[domain]/@[username]`. +// All results returned will be earlier in the alphabet than `[domain]/@[username]`. +// For example, if min_id = `example.org/@someone` then returned entries might +// contain `example.org/@earlier_account`, `earlier.example.org/@someone`, etc. +// Local account IDs in this form use an empty string for the `[domain]` part, +// for example local account with username `someone` would be `/@someone`. // - // name: limit // in: query // type: integer // description: Maximum number of results to return. -// default: 100 +// default: 50 // maximum: 200 // minimum: 1 // @@ -173,7 +182,7 @@ func (m *Module) AccountsGETV2Handler(c *gin.Context) { return } - page, errWithCode := paging.ParseIDPage(c, 1, 200, 100) + page, errWithCode := paging.ParseIDPage(c, 1, 200, 50) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/admin/accountsgetv2_test.go b/internal/api/client/admin/accountsgetv2_test.go new file mode 100644 index 000000000..fdd6c6c30 --- /dev/null +++ b/internal/api/client/admin/accountsgetv2_test.go @@ -0,0 +1,546 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package admin_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" +) + +type AccountsGetTestSuite struct { + AdminStandardTestSuite +} + +func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { + recorder := httptest.NewRecorder() + + path := admin.AccountsV2Path + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + suite.adminModule.AccountsGETV2Handler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotNil(b) + + dst := new(bytes.Buffer) + err = json.Indent(dst, b, "", " ") + if err != nil { + suite.FailNow(err.Error()) + } + + link := recorder.Header().Get("Link") + suite.Equal(`<http://localhost:8080/api/v2/admin/accounts?limit=50&max_id=xn--xample-ova.org%2F%40%C3%BCser>; rel="next", <http://localhost:8080/api/v2/admin/accounts?limit=50&min_id=%2F%401happyturtle>; rel="prev"`, link) + + suite.Equal(`[ + { + "id": "01F8MH5NBDF2MV7CTC4Q5128HF", + "username": "1happyturtle", + "domain": null, + "created_at": "2022-06-04T13:12:00.000Z", + "email": "tortle.dude@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH5NBDF2MV7CTC4Q5128HF", + "username": "1happyturtle", + "acct": "1happyturtle", + "display_name": "happy little turtle :3", + "locked": true, + "discoverable": false, + "bot": false, + "created_at": "2022-06-04T13:12:00.000Z", + "note": "<p>i post about things that concern me</p>", + "url": "http://localhost:8080/@1happyturtle", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 1, + "following_count": 1, + "statuses_count": 8, + "last_status_at": "2021-07-28T08:40:37.000Z", + "emojis": [], + "fields": [ + { + "name": "should you follow me?", + "value": "maybe!", + "verified_at": null + }, + { + "name": "age", + "value": "120", + "verified_at": null + } + ], + "hide_collections": true, + "role": { + "name": "user" + } + }, + "created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG" + }, + { + "id": "01F8MH17FWEB39HZJ76B6VXSKF", + "username": "admin", + "domain": null, + "created_at": "2022-05-17T13:10:59.000Z", + "email": "admin@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": null, + "role": { + "name": "admin" + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH17FWEB39HZJ76B6VXSKF", + "username": "admin", + "acct": "admin", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@admin", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 1, + "following_count": 1, + "statuses_count": 4, + "last_status_at": "2021-10-20T10:41:37.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "admin" + } + }, + "created_by_application_id": "01F8MGXQRHYF5QPMTMXP78QC2F" + }, + { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "domain": null, + "created_at": "2020-05-17T13:10:59.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "acct": "localhost:8080", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2020-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@localhost:8080", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + }, + { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "domain": null, + "created_at": "2022-05-20T11:09:18.000Z", + "email": "zork@example.org", + "ip": null, + "ips": [], + "locale": "en", + "invite_request": "I wanna be on this damned webbed site so bad! Please! Wow", + "role": { + "name": "user" + }, + "confirmed": true, + "approved": true, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "<p>hey yo this is my profile!</p>", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "followers_count": 2, + "following_count": 2, + "statuses_count": 7, + "last_status_at": "2023-12-10T09:24:00.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "user" + } + }, + "created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG" + }, + { + "id": "01F8MH0BBE4FHXPH513MBVFHB0", + "username": "weed_lord420", + "domain": null, + "created_at": "2022-06-04T13:12:00.000Z", + "email": "weed_lord420@example.org", + "ip": "199.222.111.89", + "ips": [], + "locale": "en", + "invite_request": "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.", + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH0BBE4FHXPH513MBVFHB0", + "username": "weed_lord420", + "acct": "weed_lord420", + "display_name": "", + "locked": false, + "discoverable": false, + "bot": false, + "created_at": "2022-06-04T13:12:00.000Z", + "note": "", + "url": "http://localhost:8080/@weed_lord420", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [], + "role": { + "name": "user" + } + }, + "created_by_application_id": "01F8MGY43H3N2C8EWPR2FPYEXG" + }, + { + "id": "01FHMQX3GAABWSM0S2VZEC2SWC", + "username": "Some_User", + "domain": "example.org", + "created_at": "2020-08-10T12:13:28.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01FHMQX3GAABWSM0S2VZEC2SWC", + "username": "Some_User", + "acct": "Some_User@example.org", + "display_name": "some user", + "locked": true, + "discoverable": true, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "i'm a real son of a gun", + "url": "http://example.org/@Some_User", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2023-11-02T10:44:25.000Z", + "emojis": [], + "fields": [] + } + }, + { + "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", + "username": "foss_satan", + "domain": "fossbros-anonymous.io", + "created_at": "2021-09-26T10:52:36.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", + "username": "foss_satan", + "acct": "foss_satan@fossbros-anonymous.io", + "display_name": "big gerald", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2021-09-26T10:52:36.000Z", + "note": "i post about like, i dunno, stuff, or whatever!!!!", + "url": "http://fossbros-anonymous.io/@foss_satan", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 3, + "last_status_at": "2021-09-11T09:40:37.000Z", + "emojis": [], + "fields": [] + } + }, + { + "id": "062G5WYKY35KKD12EMSM3F8PJ8", + "username": "her_fuckin_maj", + "domain": "thequeenisstillalive.technology", + "created_at": "2020-08-10T12:13:28.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "062G5WYKY35KKD12EMSM3F8PJ8", + "username": "her_fuckin_maj", + "acct": "her_fuckin_maj@thequeenisstillalive.technology", + "display_name": "lizzzieeeeeeeeeeee", + "locked": true, + "discoverable": true, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "if i die blame charles don't let that fuck become king", + "url": "http://thequeenisstillalive.technology/@her_fuckin_maj", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + "header_static": "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/small/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + }, + { + "id": "07GZRBAEMBNKGZ8Z9VSKSXKR98", + "username": "üser", + "domain": "ëxample.org", + "created_at": "2020-08-10T12:13:28.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "07GZRBAEMBNKGZ8Z9VSKSXKR98", + "username": "üser", + "acct": "üser@ëxample.org", + "display_name": "", + "locked": false, + "discoverable": false, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "", + "url": "https://xn--xample-ova.org/users/@%C3%BCser", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + } +]`, dst.String()) +} + +func (suite *AccountsGetTestSuite) TestAccountsMinID() { + recorder := httptest.NewRecorder() + + path := admin.AccountsV2Path + "?limit=1&min_id=/@the_mighty_zork" + ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json") + + ctx.Params = gin.Params{ + { + Key: "min_id", + Value: "/@the_mighty_zork", + }, + { + Key: "limit", + Value: "1", + }, + } + + suite.adminModule.AccountsGETV2Handler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + b, err := io.ReadAll(recorder.Body) + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotNil(b) + + dst := new(bytes.Buffer) + err = json.Indent(dst, b, "", " ") + if err != nil { + suite.FailNow(err.Error()) + } + + link := recorder.Header().Get("Link") + suite.Equal(`<http://localhost:8080/api/v2/admin/accounts?limit=1&max_id=%2F%40localhost%3A8080>; rel="next", <http://localhost:8080/api/v2/admin/accounts?limit=1&min_id=%2F%40localhost%3A8080>; rel="prev"`, link) + + suite.Equal(`[ + { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "domain": null, + "created_at": "2020-05-17T13:10:59.000Z", + "email": "", + "ip": null, + "ips": [], + "locale": "", + "invite_request": null, + "role": { + "name": "user" + }, + "confirmed": false, + "approved": false, + "disabled": false, + "silenced": false, + "suspended": false, + "account": { + "id": "01AY6P665V14JJR0AFVRT7311Y", + "username": "localhost:8080", + "acct": "localhost:8080", + "display_name": "", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2020-05-17T13:10:59.000Z", + "note": "", + "url": "http://localhost:8080/@localhost:8080", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.png", + "header_static": "http://localhost:8080/assets/default_header.png", + "followers_count": 0, + "following_count": 0, + "statuses_count": 0, + "last_status_at": null, + "emojis": [], + "fields": [] + } + } +]`, dst.String()) +} + +func TestAccountsGetTestSuite(t *testing.T) { + suite.Run(t, &AccountsGetTestSuite{}) +} diff --git a/internal/api/client/statuses/statushistory_test.go b/internal/api/client/statuses/statushistory_test.go index e524e9239..a0cb3d482 100644 --- a/internal/api/client/statuses/statushistory_test.go +++ b/internal/api/client/statuses/statushistory_test.go @@ -50,10 +50,10 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() { // Setup request. recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, target, nil) + request := httptest.NewRequest(http.MethodGet, target, nil) request.Header.Set("accept", "application/json") ctx, _ := testrig.CreateGinTestContext(recorder, request) - + // Set auth + path params. ctx.Set(oauth.SessionAuthorizedApplication, testApplication) ctx.Set(oauth.SessionAuthorizedToken, testToken) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 2b3c78aff..4e969e0ef 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -252,6 +252,32 @@ func (a *accountDB) GetInstanceAccount(ctx context.Context, domain string) (*gts return a.GetAccountByUsernameDomain(ctx, username, domain) } +// GetAccounts selects accounts using the given parameters. +// Unlike with other functions, the paging for GetAccounts +// is done not by ID, but by a concatenation of `[domain]/@[username]`, +// which allows callers to page through accounts in alphabetical +// order (much more useful for an admin overview of accounts, +// for example, than paging by ID (which is random) or by account +// created at date, which is not particularly interesting). +// +// Generated queries will look something like this +// (SQLite example, maxID was provided so we're paging down): +// +// SELECT "account"."id", (COALESCE("domain", '') || '/@' || "username") AS "domain_username" +// FROM "accounts" AS "account" +// WHERE ("domain_username" > '/@the_mighty_zork') +// ORDER BY "domain_username" ASC +// +// **NOTE ABOUT POSTGRES**: Postgres ordering expressions in +// this function specify COLLATE "C" to ensure that ordering +// is similar to SQLite (which uses BINARY ordering by default). +// This unfortunately means that A-Z > a-z, when ordering but +// that's an acceptable tradeoff for a query like this. +// +// See: +// +// - https://www.postgresql.org/docs/current/collation.html#COLLATION-MANAGING-STANDARD +// - https://sqlite.org/datatype3.html#collation func (a *accountDB) GetAccounts( ctx context.Context, origin string, @@ -269,6 +295,11 @@ func (a *accountDB) GetAccounts( error, ) { var ( + // We have to use different + // syntax for this query + // depending on dialect. + dbDialect = a.db.Dialect().Name() + // local users lists, // required for some // limiting parameters. @@ -287,10 +318,6 @@ func (a *accountDB) GetAccounts( } // Get paging params. - // - // Note this may be min_id OR since_id - // from the API, this gets handled below - // when checking order to reverse slice. minID = page.GetMin() maxID = page.GetMax() limit = page.GetLimit() @@ -309,32 +336,50 @@ func (a *accountDB) GetAccounts( // Select only IDs from table Column("account.id") - // Return only accounts OLDER - // than account with maxID. - if maxID != "" { - maxIDAcct, err := a.GetAccountByID( - gtscontext.SetBarebones(ctx), - maxID, + var subQ *bun.RawQuery + if dbDialect == dialect.SQLite { + // For SQLite we can just select + // our indexed expression once + // as a column alias. + q = q.ColumnExpr( + "(COALESCE(?, ?) || ? || ?) AS ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + bun.Ident("domain_username"), ) - if err != nil { - return nil, fmt.Errorf("error getting maxID account %s: %w", maxID, err) - } + } else { + // Create a subquery for + // Postgres to reuse. + subQ = a.db.NewRaw( + "(COALESCE(?, ?) || ? || ?) COLLATE ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + bun.Ident("C"), + ) + } - q = q.Where("? < ?", bun.Ident("account.created_at"), maxIDAcct.CreatedAt) + // Return only accounts with `[domain]/@[username]` + // later in the alphabet (a-z) than provided maxID. + if maxID != "" { + if dbDialect == dialect.SQLite { + // Use aliased column. + q = q.Where("? > ?", bun.Ident("domain_username"), maxID) + } else { + q = q.Where("? > ?", subQ, maxID) + } } - // Return only accounts NEWER - // than account with minID. + // Return only accounts with `[domain]/@[username]` + // earlier in the alphabet (a-z) than provided minID. if minID != "" { - minIDAcct, err := a.GetAccountByID( - gtscontext.SetBarebones(ctx), - minID, - ) - if err != nil { - return nil, fmt.Errorf("error getting minID account %s: %w", minID, err) + if dbDialect == dialect.SQLite { + // Use aliased column. + q = q.Where("? < ?", bun.Ident("domain_username"), minID) + } else { + q = q.Where("? < ?", subQ, minID) } - - q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt) } switch status { @@ -479,13 +524,29 @@ func (a *accountDB) GetAccounts( if order == paging.OrderAscending { // Page up. - q = q.Order("account.created_at ASC") + // It's counterintuitive because it + // says DESC in the query, but we're + // going backwards in the alphabet, + // and a < z in a string comparison. + if dbDialect == dialect.SQLite { + q = q.OrderExpr("? DESC", bun.Ident("domain_username")) + } else { + q = q.OrderExpr("(?) DESC", subQ) + } } else { // Page down. - q = q.Order("account.created_at DESC") + // It's counterintuitive because it + // says ASC in the query, but we're + // going forwards in the alphabet, + // and z > a in a string comparison. + if dbDialect == dialect.SQLite { + q = q.OrderExpr("? ASC", bun.Ident("domain_username")) + } else { + q = q.OrderExpr("? ASC", subQ) + } } - if err := q.Scan(ctx, &accountIDs); err != nil { + if err := q.Scan(ctx, &accountIDs, new([]string)); err != nil { return nil, err } diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index ea211e16f..5ed5d91a1 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -502,6 +502,80 @@ func (suite *AccountTestSuite) TestGetAccountsAll() { suite.Len(accounts, 9) } +func (suite *AccountTestSuite) TestGetAccountsMaxID() { + var ( + ctx = context.Background() + origin = "" + status = "" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip netip.Addr + // Get accounts with `[domain]/@[username]` + // later in the alphabet than `/@the_mighty_zork`. + page = &paging.Page{Max: paging.MaxID("/@the_mighty_zork")} + ) + + accounts, err := suite.db.GetAccounts( + ctx, + origin, + status, + mods, + invitedBy, + username, + displayName, + domain, + email, + ip, + page, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(accounts, 5) +} + +func (suite *AccountTestSuite) TestGetAccountsMinID() { + var ( + ctx = context.Background() + origin = "" + status = "" + mods = false + invitedBy = "" + username = "" + displayName = "" + domain = "" + email = "" + ip netip.Addr + // Get accounts with `[domain]/@[username]` + // earlier in the alphabet than `/@the_mighty_zork`. + page = &paging.Page{Min: paging.MinID("/@the_mighty_zork")} + ) + + accounts, err := suite.db.GetAccounts( + ctx, + origin, + status, + mods, + invitedBy, + username, + displayName, + domain, + email, + ip, + page, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(accounts, 3) +} + func (suite *AccountTestSuite) TestGetAccountsModsOnly() { var ( ctx = context.Background() diff --git a/internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go b/internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go new file mode 100644 index 000000000..00465cc85 --- /dev/null +++ b/internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go @@ -0,0 +1,84 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + log.Info(ctx, "reindexing accounts (accounts_paging_idx); this may take a few minutes, please don't interrupt this migration!") + + q := db.NewCreateIndex(). + TableExpr("accounts"). + Index("accounts_paging_idx"). + IfNotExists() + + switch d := db.Dialect().Name(); d { + case dialect.SQLite: + q = q.ColumnExpr( + "COALESCE(?, ?) || ? || ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + ) + + // Specify C collation for Postgres to ensure + // alphabetic sort order is similar enough to + // SQLite (which uses BINARY sort by default). + // + // See: + // + // - https://www.postgresql.org/docs/current/collation.html#COLLATION-MANAGING-STANDARD + // - https://sqlite.org/datatype3.html#collation + case dialect.PG: + q = q.ColumnExpr( + "(COALESCE(?, ?) || ? || ?) COLLATE ?", + bun.Ident("domain"), "", + "/@", + bun.Ident("username"), + bun.Ident("C"), + ) + + default: + log.Panicf(ctx, "dialect %s was neither postgres nor sqlite", d) + } + + if _, err := q.Exec(ctx); err != nil { + return err + } + + return nil + + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/processing/admin/accounts.go b/internal/processing/admin/accounts.go index ca35b0a30..ba2a88ce6 100644 --- a/internal/processing/admin/accounts.go +++ b/internal/processing/admin/accounts.go @@ -115,8 +115,12 @@ func (p *Processor) AccountsGet( return paging.EmptyResponse(), nil } - hi := accounts[count-1].ID - lo := accounts[0].ID + var ( + loAcct = accounts[count-1] + hiAcct = accounts[0] + lo = loAcct.Domain + "/@" + loAcct.Username + hi = hiAcct.Domain + "/@" + hiAcct.Username + ) items := make([]interface{}, 0, count) for _, account := range accounts { diff --git a/web/source/package.json b/web/source/package.json index 919bf3c83..230c248ad 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -22,6 +22,7 @@ "nanoid": "^4.0.0", "object-to-formdata": "^4.4.2", "papaparse": "^5.3.2", + "parse-link-header": "^2.0.0", "photoswipe": "^5.3.3", "photoswipe-dynamic-caption-plugin": "^1.2.7", "plyr": "^3.7.8", @@ -44,6 +45,7 @@ "@joepie91/eslint-config": "^1.1.1", "@types/is-valid-domain": "^0.0.2", "@types/papaparse": "^5.3.9", + "@types/parse-link-header": "^2.0.3", "@types/psl": "^1.1.1", "@types/react-dom": "^18.2.8", "@typescript-eslint/eslint-plugin": "^6.7.4", diff --git a/web/source/settings/components/account-list.tsx b/web/source/settings/components/account-list.tsx deleted file mode 100644 index c4420b5bc..000000000 --- a/web/source/settings/components/account-list.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -import React from "react"; -import { Link } from "wouter"; -import { Error } from "./error"; -import { AdminAccount } from "../lib/types/account"; -import { SerializedError } from "@reduxjs/toolkit"; -import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; - -export interface AccountListProps { - isSuccess: boolean, - data: AdminAccount[] | undefined, - isLoading: boolean, - isError: boolean, - error: FetchBaseQueryError | SerializedError | undefined, - emptyMessage: string, -} - -export function AccountList({ - isLoading, - isSuccess, - data, - isError, - error, - emptyMessage, -}: AccountListProps) { - if (!(isSuccess || isError)) { - // Hasn't been called yet. - return null; - } - - if (isLoading) { - return <i - className="fa fa-fw fa-refresh fa-spin" - aria-hidden="true" - title="Loading..." - />; - } - - if (error) { - return <Error error={error} />; - } - - if (data == undefined || data.length == 0) { - return <b>{emptyMessage}</b>; - } - - return ( - <div className="list"> - {data.map(({ account: acc }) => ( - <Link - key={acc.acct} - className="account entry" - href={`/${acc.id}`} - > - {acc.display_name?.length > 0 - ? acc.display_name - : acc.username - } - <span id="username">(@{acc.acct})</span> - </Link> - ))} - </div> - ); -} diff --git a/web/source/settings/components/pageable-list.tsx b/web/source/settings/components/pageable-list.tsx new file mode 100644 index 000000000..918103ead --- /dev/null +++ b/web/source/settings/components/pageable-list.tsx @@ -0,0 +1,113 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { ReactNode } from "react"; +import { useLocation } from "wouter"; +import { Error } from "./error"; +import { SerializedError } from "@reduxjs/toolkit"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { Links } from "parse-link-header"; +import Loading from "./loading"; + +export interface PageableListProps<T> { + isSuccess: boolean; + items?: T[]; + itemToEntry: (_item: T) => ReactNode; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: FetchBaseQueryError | SerializedError | undefined; + emptyMessage: string; + prevNextLinks?: Links | null | undefined; +} + +export function PageableList<T>({ + isLoading, + isFetching, + isSuccess, + items, + itemToEntry, + isError, + error, + emptyMessage, + prevNextLinks, +}: PageableListProps<T>) { + const [ location, setLocation ] = useLocation(); + + if (!(isSuccess || isError)) { + // Hasn't been called yet. + return null; + } + + if (isLoading || isFetching) { + return <Loading />; + } + + if (error) { + return <Error error={error} />; + } + + // Map response to items if possible. + let content: ReactNode; + if (items == undefined || items.length == 0) { + content = <b>{emptyMessage}</b>; + } else { + content = ( + <div className="entries"> + {items.map(item => itemToEntry(item))} + </div> + ); + } + + // If it's possible to page to next and previous + // pages, instantiate button handlers for this. + let prevClick: (() => void) | undefined; + let nextClick: (() => void) | undefined; + if (prevNextLinks) { + const prev = prevNextLinks["prev"]; + if (prev) { + const prevUrl = new URL(prev.url); + const prevParams = prevUrl.search; + prevClick = () => { + setLocation(location + prevParams.toString()); + }; + } + + const next = prevNextLinks["next"]; + if (next) { + const nextUrl = new URL(next.url); + const nextParams = nextUrl.search; + nextClick = () => { + setLocation(location + nextParams.toString()); + }; + } + } + + return ( + <div className="list pageable-list"> + { content } + { prevNextLinks && + <div className="prev-next"> + { prevClick && <button onClick={prevClick}>Previous page</button> } + { nextClick && <button onClick={nextClick}>Next page</button> } + </div> + } + </div> + ); +} diff --git a/web/source/settings/views/moderation/reports/username.tsx b/web/source/settings/components/username.tsx index 294d97e8b..f7be1cd4a 100644 --- a/web/source/settings/views/moderation/reports/username.tsx +++ b/web/source/settings/components/username.tsx @@ -18,19 +18,23 @@ */ import React from "react"; -import { Link } from "wouter"; -import { AdminAccount } from "../../../lib/types/account"; +import { useLocation } from "wouter"; +import { AdminAccount } from "../lib/types/account"; interface UsernameProps { - user: AdminAccount; - link?: string; + account: AdminAccount; + linkTo?: string; + backLocation?: string; + classNames?: string[]; } -export default function Username({ user, link }: UsernameProps) { - let className = "user"; - let isLocal = user.domain == null; +export default function Username({ account, linkTo, backLocation, classNames }: UsernameProps) { + const [ _location, setLocation ] = useLocation(); + + let className = "username-lozenge"; + let isLocal = account.domain == null; - if (user.suspended) { + if (account.suspended) { className += " suspended"; } @@ -38,23 +42,43 @@ export default function Username({ user, link }: UsernameProps) { className += " local"; } + if (classNames) { + className = [ className, classNames ].flat().join(" "); + } + let icon = isLocal ? { fa: "fa-home", info: "Local user" } : { fa: "fa-external-link-square", info: "Remote user" }; const content = ( <> - <span className="acct">@{user.account.acct}</span> <i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} /> <span className="sr-only">{icon.info}</span> + + <span className="acct">@{account.account.acct}</span> </> ); - if (link) { + if (linkTo) { + className += " spanlink"; return ( - <Link className={className} to={link}> + <span + className={className} + onClick={() => { + // When clicking on an account, direct + // to the detail view for that account. + setLocation(linkTo, { + // Store the back location in history so + // the detail view can use it to return to + // this page (including query parameters). + state: { backLocation: backLocation } + }); + }} + role="link" + tabIndex={0} + > {content} - </Link> + </span> ); } else { return ( diff --git a/web/source/settings/index.tsx b/web/source/settings/index.tsx index 977a94150..25e3d1f3c 100644 --- a/web/source/settings/index.tsx +++ b/web/source/settings/index.tsx @@ -59,11 +59,10 @@ export function App({ account }: AppProps) { <ModerationRouter /> <AdminRouter /> {/* - Redirect to first part of UserRouter if - just the bare settings page is open, so - user isn't greeted with a blank page. - */} - <Route><Redirect to="/user/profile" /></Route> + Ensure user ends up somewhere + if they just open /settings. + */} + <Route path="/"><Redirect to="/user" /></Route> </ErrorBoundary> </Router> </section> diff --git a/web/source/settings/lib/query/admin/index.ts b/web/source/settings/lib/query/admin/index.ts index cbe66705b..3e7b1a0a0 100644 --- a/web/source/settings/lib/query/admin/index.ts +++ b/web/source/settings/lib/query/admin/index.ts @@ -20,8 +20,9 @@ import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers"; import { gtsApi } from "../gts-api"; import { listToKeyedObject } from "../transforms"; -import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account"; +import { AdminAccount, HandleSignupParams, SearchAccountParams, SearchAccountResp } from "../../types/account"; import { InstanceRule, MappedRules } from "../../types/rules"; +import parse from "parse-link-header"; const extended = gtsApi.injectEndpoints({ endpoints: (build) => ({ @@ -65,7 +66,7 @@ const extended = gtsApi.injectEndpoints({ ], }), - searchAccounts: build.query<AdminAccount[], SearchAccountParams>({ + searchAccounts: build.query<SearchAccountResp, SearchAccountParams>({ query: (form) => { const params = new(URLSearchParams); Object.entries(form).forEach(([k, v]) => { @@ -83,10 +84,16 @@ const extended = gtsApi.injectEndpoints({ url: `/api/v2/admin/accounts${query}` }; }, + transformResponse: (apiResp: AdminAccount[], meta) => { + const accounts = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { accounts, links }; + }, providesTags: (res) => res ? [ - ...res.map(({ id }) => ({ type: 'Account' as const, id })), + ...res.accounts.map(({ id }) => ({ type: 'Account' as const, id })), { type: 'Account', id: 'LIST' }, ] : [{ type: 'Account', id: 'LIST' }], diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index a07f5ff1e..6e5eafeab 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -24,7 +24,7 @@ import type { FetchBaseQueryError, } from '@reduxjs/toolkit/query/react'; import { serialize as serializeForm } from "object-to-formdata"; - +import type { FetchBaseQueryMeta } from "@reduxjs/toolkit/dist/query/fetchBaseQuery"; import type { RootState } from '../../redux/store'; import { InstanceV1 } from '../types/instance'; @@ -65,7 +65,9 @@ export interface GTSFetchArgs extends FetchArgs { const gtsBaseQuery: BaseQueryFn< string | GTSFetchArgs, any, - FetchBaseQueryError + FetchBaseQueryError, + {}, + FetchBaseQueryMeta > = async (args, api, extraOptions) => { // Retrieve state at the moment // this function was called. diff --git a/web/source/settings/lib/types/account.ts b/web/source/settings/lib/types/account.ts index 3e7e9640d..db97001ac 100644 --- a/web/source/settings/lib/types/account.ts +++ b/web/source/settings/lib/types/account.ts @@ -17,6 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ +import { Links } from "parse-link-header"; import { CustomEmoji } from "./custom-emoji"; export interface AdminAccount { @@ -79,6 +80,11 @@ export interface SearchAccountParams { limit?: number, } +export interface SearchAccountResp { + accounts: AdminAccount[]; + links: Links | null; +} + export interface HandleSignupParams { id: string, approve_or_reject: "approve" | "reject", diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 5af9dbc67..01263c224 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -16,6 +16,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ +/* + This source file uses PostCSS syntax. + See: https://postcss.org/ +*/ + body { grid-template-rows: auto 1fr; } @@ -521,6 +526,22 @@ span.form-info { } } +.pageable-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .entries { + color: $fg; + border: 0.1rem solid var(--gray1); + } + + .prev-next { + display: flex; + justify-content: space-between; + } +} + .domain-permissions-list { p { margin-top: 0; @@ -1098,49 +1119,58 @@ button.with-padding { } } } +} - .user { - line-height: 1.3rem; - display: inline-block; - background: $fg-accent; - color: $bg; - border-radius: $br; - padding: 0.15rem 0.15rem; - margin: 0 0.1rem; - font-weight: bold; - text-decoration: none; - - .acct { - word-break: break-all; - } - - &.suspended { - background: $bg-accent; - color: $fg; - text-decoration: line-through; - } +.username-lozenge { + line-height: 1.3rem; + display: inline-block; + background: $fg-accent; + color: $bg; + border-radius: $br; + padding: 0.15rem; + font-weight: bold; + text-decoration: none; + + .acct { + word-break: break-all; + } - &.local { - background: $green1; - } + &.suspended { + background: $bg-accent; + color: $fg; + text-decoration: line-through; } -} -.accounts-view { - form { - margin-bottom: 1rem; + &.local { + background: $green1; } +} - .list { - margin: 0.5rem 0; +.spanlink { + cursor: pointer; + text-decoration: none; +} - a { +.accounts-view { + .pageable-list { + .username-lozenge { + line-height: inherit; color: $fg; - text-decoration: none; + font-weight: initial; + width: 100%; + border-radius: 0; + background: $list-entry-bg; + + .fa { + align-self: center; + } + + &:nth-child(even) { + background: $list-entry-alternate-bg; + } - #username { - color: $link-fg; - margin-left: 0.5em; + .acct { + color: var(--link-fg); } } } @@ -1154,6 +1184,7 @@ button.with-padding { .profile { overflow: hidden; max-width: 60rem; + margin-top: 1rem; } h4, h3, h2 { @@ -1185,6 +1216,16 @@ button.with-padding { dd { word-break: break-word; } + + dt, dd { + /* + Make sure any fa icons used in keys + or values are properly aligned. + */ + .fa { + vertical-align: middle; + } + } } } diff --git a/web/source/settings/views/moderation/accounts/detail/actions.tsx b/web/source/settings/views/moderation/accounts/detail/actions.tsx index 212bb4089..4132b778a 100644 --- a/web/source/settings/views/moderation/accounts/detail/actions.tsx +++ b/web/source/settings/views/moderation/accounts/detail/actions.tsx @@ -19,7 +19,7 @@ import React from "react"; -import { useActionAccountMutation } from "../../../../lib/query/admin"; +import { useActionAccountMutation, useHandleSignupMutation } from "../../../../lib/query/admin"; import MutationButton from "../../../../components/form/mutation-button"; import useFormSubmit from "../../../../lib/form/submit"; import { @@ -27,22 +27,50 @@ import { useTextInput, useBoolInput, } from "../../../../lib/form"; -import { Checkbox, TextInput } from "../../../../components/form/inputs"; +import { Checkbox, Select, TextInput } from "../../../../components/form/inputs"; import { AdminAccount } from "../../../../lib/types/account"; +import { useLocation } from "wouter"; export interface AccountActionsProps { account: AdminAccount, + backLocation: string, } -export function AccountActions({ account }: AccountActionsProps) { +export function AccountActions({ account, backLocation }: AccountActionsProps) { + const local = !account.domain; + + // Available actions differ depending + // on the account's current status. + switch (true) { + case account.suspended: + // Can't do anything with + // suspended accounts currently. + return null; + case local && !account.approved: + // Unapproved local account sign-up, + // only show HandleSignup form. + return ( + <HandleSignup + account={account} + backLocation={backLocation} + /> + ); + default: + // Normal local or remote account, show + // full range of moderation options. + return <ModerateAccount account={account} />; + } +} + +function ModerateAccount({ account }: { account: AdminAccount }) { const form = { id: useValue("id", account.id), reason: useTextInput("text") }; - + const reallySuspend = useBoolInput("reallySuspend"); const [accountAction, result] = useFormSubmit(form, useActionAccountMutation()); - + return ( <form onSubmit={accountAction} @@ -60,16 +88,6 @@ export function AccountActions({ account }: AccountActionsProps) { placeholder="Reason for this action" /> <div className="action-buttons"> - {/* <MutationButton - label="Disable" - name="disable" - result={result} - /> - <MutationButton - label="Silence" - name="silence" - result={result} - /> */} <MutationButton disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false} label="Suspend" @@ -84,3 +102,81 @@ export function AccountActions({ account }: AccountActionsProps) { </form> ); } + +function HandleSignup({ account, backLocation }: { account: AdminAccount, backLocation: string }) { + const form = { + id: useValue("id", account.id), + approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }), + privateComment: useTextInput("private_comment"), + message: useTextInput("message"), + sendEmail: useBoolInput("send_email"), + }; + + const [_location, setLocation] = useLocation(); + + const [handleSignup, result] = useFormSubmit(form, useHandleSignupMutation(), { + changedOnly: false, + // After submitting the form, redirect back to + // /settings/admin/accounts if rejecting, since + // account will no longer be available at + // /settings/admin/accounts/:accountID endpoint. + onFinish: (res) => { + if (form.approveOrReject.value === "approve") { + // An approve request: + // stay on this page and + // serve updated details. + return; + } + + if (res.data) { + // "reject" successful, + // redirect to accounts page. + setLocation(backLocation); + } + } + }); + + return ( + <form + onSubmit={handleSignup} + aria-labelledby="account-handle-signup" + > + <h3 id="account-handle-signup">Handle Account Sign-Up</h3> + <Select + field={form.approveOrReject} + label="Approve or Reject" + options={ + <> + <option value="approve">Approve</option> + <option value="reject">Reject</option> + </> + } + > + </Select> + { form.approveOrReject.value === "reject" && + // Only show form fields relevant + // to "reject" if rejecting. + // On "approve" these fields will + // be ignored anyway. + <> + <TextInput + field={form.privateComment} + label="(Optional) private comment on why sign-up was rejected (shown to other admins only)" + /> + <Checkbox + field={form.sendEmail} + label="Send email to applicant" + /> + <TextInput + field={form.message} + label={"(Optional) message to include in email to applicant, if send email is checked"} + /> + </> } + <MutationButton + disabled={false} + label={form.approveOrReject.value === "approve" ? "Approve" : "Reject"} + result={result} + /> + </form> + ); +} diff --git a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx b/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx deleted file mode 100644 index 59fa8bc65..000000000 --- a/web/source/settings/views/moderation/accounts/detail/handlesignup.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -import React from "react"; -import { useLocation } from "wouter"; -import { useHandleSignupMutation } from "../../../../lib/query/admin"; -import MutationButton from "../../../../components/form/mutation-button"; -import useFormSubmit from "../../../../lib/form/submit"; -import { - useValue, - useTextInput, - useBoolInput, -} from "../../../../lib/form"; -import { Checkbox, Select, TextInput } from "../../../../components/form/inputs"; -import { AdminAccount } from "../../../../lib/types/account"; - -export interface HandleSignupProps { - account: AdminAccount, - backLocation: string, -} - -export function HandleSignup({account, backLocation}: HandleSignupProps) { - const form = { - id: useValue("id", account.id), - approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }), - privateComment: useTextInput("private_comment"), - message: useTextInput("message"), - sendEmail: useBoolInput("send_email"), - }; - - const [_location, setLocation] = useLocation(); - - const [handleSignup, result] = useFormSubmit(form, useHandleSignupMutation(), { - changedOnly: false, - // After submitting the form, redirect back to - // /settings/admin/accounts if rejecting, since - // account will no longer be available at - // /settings/admin/accounts/:accountID endpoint. - onFinish: (res) => { - if (form.approveOrReject.value === "approve") { - // An approve request: - // stay on this page and - // serve updated details. - return; - } - - if (res.data) { - // "reject" successful, - // redirect to accounts page. - setLocation(backLocation); - } - } - }); - - return ( - <form - onSubmit={handleSignup} - aria-labelledby="account-handle-signup" - > - <h3 id="account-handle-signup">Handle Account Sign-Up</h3> - <Select - field={form.approveOrReject} - label="Approve or Reject" - options={ - <> - <option value="approve">Approve</option> - <option value="reject">Reject</option> - </> - } - > - </Select> - { form.approveOrReject.value === "reject" && - // Only show form fields relevant - // to "reject" if rejecting. - // On "approve" these fields will - // be ignored anyway. - <> - <TextInput - field={form.privateComment} - label="(Optional) private comment on why sign-up was rejected (shown to other admins only)" - /> - <Checkbox - field={form.sendEmail} - label="Send email to applicant" - /> - <TextInput - field={form.message} - label={"(Optional) message to include in email to applicant, if send email is checked"} - /> - </> } - <MutationButton - disabled={false} - label={form.approveOrReject.value === "approve" ? "Approve" : "Reject"} - result={result} - /> - </form> - ); -} diff --git a/web/source/settings/views/moderation/accounts/detail/index.tsx b/web/source/settings/views/moderation/accounts/detail/index.tsx index f34bc7481..830a894cb 100644 --- a/web/source/settings/views/moderation/accounts/detail/index.tsx +++ b/web/source/settings/views/moderation/accounts/detail/index.tsx @@ -23,51 +23,89 @@ import { useGetAccountQuery } from "../../../../lib/query/admin"; import FormWithData from "../../../../lib/form/form-with-data"; import FakeProfile from "../../../../components/fake-profile"; import { AdminAccount } from "../../../../lib/types/account"; -import { HandleSignup } from "./handlesignup"; import { AccountActions } from "./actions"; import { useParams } from "wouter"; +import { useBaseUrl } from "../../../../lib/navigation/util"; +import BackButton from "../../../../components/back-button"; +import { UseOurInstanceAccount, yesOrNo } from "./util"; export default function AccountDetail() { const params: { accountID: string } = useParams(); - + const baseUrl = useBaseUrl(); + const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`; + return ( <div className="account-detail"> - <h1>Account Details</h1> + <h1><BackButton to={backLocation} /> Account Details</h1> <FormWithData dataQuery={useGetAccountQuery} queryArg={params.accountID} DataForm={AccountDetailForm} + {...{ backLocation: backLocation }} /> </div> ); } interface AccountDetailFormProps { - backLocation: string, - data: AdminAccount, + data: AdminAccount; + backLocation: string; } -function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) { - let yesOrNo = (b: boolean) => { - return b ? "yes" : "no"; - }; +function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) { + // If this is our instance account, don't + // bother returning detailed account information. + const ourInstanceAccount = UseOurInstanceAccount(adminAcct); + if (ourInstanceAccount) { + return ( + <> + <FakeProfile {...adminAcct.account} /> + <div className="info"> + <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> + <b> + This is the service account for your instance; you + cannot perform moderation actions on this account. + </b> + </div> + </> + ); + } + + const local = !adminAcct.domain; + return ( + <> + <FakeProfile {...adminAcct.account} /> + <GeneralAccountDetails adminAcct={adminAcct} /> + { + // Only show local account details + // if this is a local account! + local && <LocalAccountDetails adminAcct={adminAcct} /> + } + <AccountActions + account={adminAcct} + backLocation={backLocation} + /> + </> + ); +} - let created = new Date(adminAcct.created_at).toDateString(); +function GeneralAccountDetails({ adminAcct } : { adminAcct: AdminAccount }) { + const local = !adminAcct.domain; + const created = new Date(adminAcct.created_at).toDateString(); + let lastPosted = "never"; if (adminAcct.account.last_status_at) { lastPosted = new Date(adminAcct.account.last_status_at).toDateString(); } - const local = !adminAcct.domain; return ( <> - <FakeProfile {...adminAcct.account} /> <h3>General Account Details</h3> - { adminAcct.suspended && - <div className="info"> - <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> - <b>Account is suspended.</b> - </div> + { adminAcct.suspended && + <div className="info"> + <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> + <b>Account is suspended.</b> + </div> } <dl className="info-list"> { !local && @@ -76,6 +114,18 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP <dd>{adminAcct.domain}</dd> </div>} <div className="info-list-entry"> + <dt>Profile URL</dt> + <dd> + <a + href={adminAcct.account.url} + target="_blank" + rel="noreferrer" + > + <i className="fa fa-fw fa-external-link" aria-hidden="true"></i> {adminAcct.account.url} (opens in a new tab) + </a> + </dd> + </div> + <div className="info-list-entry"> <dt>Created</dt> <dd><time dateTime={adminAcct.created_at}>{created}</time></dd> </div> @@ -104,61 +154,54 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP <dd>{adminAcct.account.following_count}</dd> </div> </dl> - { local && - // Only show local account details - // if this is a local account! - <> - <h3>Local Account Details</h3> - { !adminAcct.approved && + </> + ); +} + +function LocalAccountDetails({ adminAcct }: { adminAcct: AdminAccount }) { + return ( + <> + <h3>Local Account Details</h3> + { !adminAcct.approved && <div className="info"> <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> <b>Account is pending.</b> </div> - } - { !adminAcct.confirmed && + } + { !adminAcct.confirmed && <div className="info"> <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> <b>Account email not yet confirmed.</b> </div> - } - <dl className="info-list"> - <div className="info-list-entry"> - <dt>Email</dt> - <dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd> - </div> - <div className="info-list-entry"> - <dt>Disabled</dt> - <dd>{yesOrNo(adminAcct.disabled)}</dd> - </div> - <div className="info-list-entry"> - <dt>Approved</dt> - <dd>{yesOrNo(adminAcct.approved)}</dd> - </div> - <div className="info-list-entry"> - <dt>Sign-Up Reason</dt> - <dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd> - </div> - { (adminAcct.ip && adminAcct.ip !== "0.0.0.0") && + } + <dl className="info-list"> + <div className="info-list-entry"> + <dt>Email</dt> + <dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd> + </div> + <div className="info-list-entry"> + <dt>Disabled</dt> + <dd>{yesOrNo(adminAcct.disabled)}</dd> + </div> + <div className="info-list-entry"> + <dt>Approved</dt> + <dd>{yesOrNo(adminAcct.approved)}</dd> + </div> + <div className="info-list-entry"> + <dt>Sign-Up Reason</dt> + <dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd> + </div> + { (adminAcct.ip && adminAcct.ip !== "0.0.0.0") && <div className="info-list-entry"> <dt>Sign-Up IP</dt> <dd>{adminAcct.ip}</dd> </div> } - { adminAcct.locale && + { adminAcct.locale && <div className="info-list-entry"> <dt>Locale</dt> <dd>{adminAcct.locale}</dd> </div> } - </dl> - </> } - { local && !adminAcct.approved - ? - <HandleSignup - account={adminAcct} - backLocation={backLocation} - /> - : - <AccountActions account={adminAcct} /> - } - </> + </dl> + </> ); } diff --git a/web/source/settings/views/moderation/accounts/detail/util.tsx b/web/source/settings/views/moderation/accounts/detail/util.tsx new file mode 100644 index 000000000..b82d44a6e --- /dev/null +++ b/web/source/settings/views/moderation/accounts/detail/util.tsx @@ -0,0 +1,43 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +import { useMemo } from "react"; + +import { AdminAccount } from "../../../../lib/types/account"; +import { store } from "../../../../redux/store"; + +export function yesOrNo(b: boolean): string { + return b ? "yes" : "no"; +} + +export function UseOurInstanceAccount(account: AdminAccount): boolean { + // Pull our own URL out of storage so we can + // tell if account is our instance account. + const ourDomain = useMemo(() => { + const instanceUrlStr = store.getState().oauth.instanceUrl; + if (!instanceUrlStr) { + return ""; + } + + const instanceUrl = new URL(instanceUrlStr); + return instanceUrl.host; + }, []); + + return !account.domain && account.username == ourDomain; +} diff --git a/web/source/settings/views/moderation/accounts/index.tsx b/web/source/settings/views/moderation/accounts/index.tsx index 79ba2c674..946ed323d 100644 --- a/web/source/settings/views/moderation/accounts/index.tsx +++ b/web/source/settings/views/moderation/accounts/index.tsx @@ -20,10 +20,10 @@ import React from "react"; import { AccountSearchForm } from "./search"; -export default function AccountsOverview({ }) { +export default function AccountsSearch({ }) { return ( <div className="accounts-view"> - <h1>Accounts Overview</h1> + <h1>Accounts Search</h1> <span> You can perform actions on an account by clicking its name in a report, or by searching for the account diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx index d5a32f09b..b72de52bf 100644 --- a/web/source/settings/views/moderation/accounts/pending/index.tsx +++ b/web/source/settings/views/moderation/accounts/pending/index.tsx @@ -17,20 +17,40 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -import React from "react"; +import React, { ReactNode } from "react"; import { useSearchAccountsQuery } from "../../../../lib/query/admin"; -import { AccountList } from "../../../../components/account-list"; +import { PageableList } from "../../../../components/pageable-list"; +import { useLocation } from "wouter"; +import Username from "../../../../components/username"; +import { AdminAccount } from "../../../../lib/types/account"; export default function AccountsPending() { + const [ location, _setLocation ] = useLocation(); const searchRes = useSearchAccountsQuery({status: "pending"}); + // Function to map an item to a list entry. + function itemToEntry(account: AdminAccount): ReactNode { + const acc = account.account; + return ( + <Username + key={acc.acct} + account={account} + linkTo={`/${account.id}`} + backLocation={location} + classNames={["entry"]} + /> + ); + } + return ( <div className="accounts-view"> <h1>Pending Accounts</h1> - <AccountList + <PageableList isLoading={searchRes.isLoading} + isFetching={searchRes.isFetching} isSuccess={searchRes.isSuccess} - data={searchRes.data} + items={searchRes.data?.accounts} + itemToEntry={itemToEntry} isError={searchRes.isError} error={searchRes.error} emptyMessage="No pending account sign-ups." diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx index 8ee579e16..58e2d6505 100644 --- a/web/source/settings/views/moderation/accounts/search/index.tsx +++ b/web/source/settings/views/moderation/accounts/search/index.tsx @@ -17,28 +17,53 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -import React from "react"; +import React, { ReactNode, useEffect, useMemo } from "react"; import { useLazySearchAccountsQuery } from "../../../../lib/query/admin"; import { useTextInput } from "../../../../lib/form"; -import { AccountList } from "../../../../components/account-list"; -import { SearchAccountParams } from "../../../../lib/types/account"; +import { PageableList } from "../../../../components/pageable-list"; import { Select, TextInput } from "../../../../components/form/inputs"; import MutationButton from "../../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { AdminAccount } from "../../../../lib/types/account"; +import Username from "../../../../components/username"; export function AccountSearchForm() { + const [ location, setLocation ] = useLocation(); + const search = useSearch(); + const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); + const [ searchAcct, searchRes ] = useLazySearchAccountsQuery(); + + // Populate search form using values from + // urlQueryParams, to allow paging. const form = { - origin: useTextInput("origin"), - status: useTextInput("status"), - permissions: useTextInput("permissions"), - username: useTextInput("username"), - display_name: useTextInput("display_name"), - by_domain: useTextInput("by_domain"), - email: useTextInput("email"), - ip: useTextInput("ip"), + origin: useTextInput("origin", { defaultValue: urlQueryParams.get("origin") ?? ""}), + status: useTextInput("status", { defaultValue: urlQueryParams.get("status") ?? ""}), + permissions: useTextInput("permissions", { defaultValue: urlQueryParams.get("permissions") ?? ""}), + username: useTextInput("username", { defaultValue: urlQueryParams.get("username") ?? ""}), + display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}), + by_domain: useTextInput("by_domain", { defaultValue: urlQueryParams.get("by_domain") ?? ""}), + email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}), + ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}), + limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "50"}) }; - function submitSearch(e) { + // On mount, if urlQueryParams were provided, + // trigger the search. For example, if page + // was accessed at /search?origin=local&limit=20, + // then run a search with origin=local and + // limit=20 and immediately render the results. + useEffect(() => { + if (urlQueryParams.size > 0) { + searchAcct(Object.fromEntries(urlQueryParams), true); + } + }, [urlQueryParams, searchAcct]); + + // Rather than triggering the search directly, + // the "submit" button changes the location + // based on form field params, and lets the + // useEffect hook above actually do the search. + function submitQuery(e) { e.preventDefault(); // Parse query parameters. @@ -52,16 +77,32 @@ export function AccountSearchForm() { // Remove any nulls. return kv || []; }); - const params: SearchAccountParams = Object.fromEntries(entries); - searchAcct(params); + + const searchParams = new URLSearchParams(entries); + setLocation(location + "?" + searchParams.toString()); } - const [ searchAcct, searchRes ] = useLazySearchAccountsQuery(); + // Location to return to when user clicks "back" on the account detail view. + const backLocation = location + (urlQueryParams ? `?${urlQueryParams}` : ""); + + // Function to map an item to a list entry. + function itemToEntry(account: AdminAccount): ReactNode { + const acc = account.account; + return ( + <Username + key={acc.acct} + account={account} + linkTo={`/${account.id}`} + backLocation={backLocation} + classNames={["entry"]} + /> + ); + } return ( <> <form - onSubmit={submitSearch} + onSubmit={submitQuery} // Prevent password managers trying // to fill in username/email fields. autoComplete="off" @@ -117,13 +158,16 @@ export function AccountSearchForm() { result={searchRes} /> </form> - <AccountList + <PageableList isLoading={searchRes.isLoading} + isFetching={searchRes.isFetching} isSuccess={searchRes.isSuccess} - data={searchRes.data} + items={searchRes.data?.accounts} + itemToEntry={itemToEntry} isError={searchRes.isError} error={searchRes.error} emptyMessage="No accounts found that match your query" + prevNextLinks={searchRes.data?.links} /> </> ); diff --git a/web/source/settings/views/moderation/menu.tsx b/web/source/settings/views/moderation/menu.tsx index 4f01e0798..9488b8c30 100644 --- a/web/source/settings/views/moderation/menu.tsx +++ b/web/source/settings/views/moderation/menu.tsx @@ -28,7 +28,7 @@ import { useHasPermission } from "../../lib/navigation/util"; /** * - /settings/moderation/reports/overview * - /settings/moderation/reports/:reportId - * - /settings/moderation/accounts/overview + * - /settings/moderation/accounts/search * - /settings/moderation/accounts/pending * - /settings/moderation/accounts/:accountID * - /settings/moderation/domain-permissions/:permType @@ -76,12 +76,12 @@ function ModerationAccountsMenu() { <MenuItem name="Accounts" itemUrl="accounts" - defaultChild="overview" + defaultChild="search" icon="fa-users" > <MenuItem - name="Overview" - itemUrl="overview" + name="Search" + itemUrl="search" icon="fa-list" /> <MenuItem diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx index bc356edce..ad8d69a47 100644 --- a/web/source/settings/views/moderation/reports/detail.tsx +++ b/web/source/settings/views/moderation/reports/detail.tsx @@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form"; import useFormSubmit from "../../../lib/form/submit"; import { TextArea } from "../../../components/form/inputs"; import MutationButton from "../../../components/form/mutation-button"; -import Username from "./username"; +import Username from "../../../components/username"; import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports"; import { useBaseUrl } from "../../../lib/navigation/util"; @@ -53,13 +53,15 @@ function ReportDetailForm({ data: report }) { <div className="report detail"> <div className="usernames"> <Username - user={from} - link={`~/settings/moderation/accounts/${from.id}`} + account={from} + linkTo={`~/settings/moderation/accounts/${from.id}`} + backLocation={`~/settings/moderation/reports/${report.id}`} /> <> reported </> <Username - user={target} - link={`~/settings/moderation/accounts/${target.id}`} + account={target} + linkTo={`~/settings/moderation/accounts/${target.id}`} + backLocation={`~/settings/moderation/reports/${report.id}`} /> </div> diff --git a/web/source/settings/views/moderation/reports/overview.tsx b/web/source/settings/views/moderation/reports/overview.tsx index 03ce1a382..18eb5492a 100644 --- a/web/source/settings/views/moderation/reports/overview.tsx +++ b/web/source/settings/views/moderation/reports/overview.tsx @@ -20,7 +20,7 @@ import React from "react"; import { Link } from "wouter"; import FormWithData from "../../../lib/form/form-with-data"; -import Username from "./username"; +import Username from "../../../components/username"; import { useListReportsQuery } from "../../../lib/query/admin/reports"; export function ReportOverview({ }) { @@ -75,7 +75,7 @@ function ReportEntry({ report }) { <div className={`report entry${report.action_taken ? " resolved" : ""}`}> <div className="byline"> <div className="usernames"> - <Username user={from} /> reported <Username user={target} /> + <Username account={from} /> reported <Username account={target} /> </div> <h3 className="report-status"> {report.action_taken ? "Resolved" : "Open"} diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index 37344462b..d23ab336a 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -26,7 +26,7 @@ import { ErrorBoundary } from "../../lib/navigation/error"; import ImportExport from "./domain-permissions/import-export"; import DomainPermissionsOverview from "./domain-permissions/overview"; import DomainPermDetail from "./domain-permissions/detail"; -import AccountsOverview from "./accounts"; +import AccountsSearch from "./accounts"; import AccountsPending from "./accounts/pending"; import AccountDetail from "./accounts/detail"; @@ -37,7 +37,7 @@ import AccountDetail from "./accounts/detail"; /** * - /settings/moderation/reports/overview * - /settings/moderation/reports/:reportId - * - /settings/moderation/accounts/overview + * - /settings/moderation/accounts/search * - /settings/moderation/accounts/pending * - /settings/moderation/accounts/:accountID * - /settings/moderation/domain-permissions/:permType @@ -95,7 +95,7 @@ function ModerationReportsRouter() { } /** - * - /settings/moderation/accounts/overview + * - /settings/moderation/accounts/search * - /settings/moderation/accounts/pending * - /settings/moderation/accounts/:accountID */ @@ -109,10 +109,10 @@ function ModerationAccountsRouter() { <Router base={thisBase}> <ErrorBoundary> <Switch> - <Route path="/overview" component={AccountsOverview}/> + <Route path="/search" component={AccountsSearch}/> <Route path="/pending" component={AccountsPending}/> <Route path="/:accountID" component={AccountDetail}/> - <Route><Redirect to="/overview"/></Route> + <Route><Redirect to="/search"/></Route> </Switch> </ErrorBoundary> </Router> diff --git a/web/source/yarn.lock b/web/source/yarn.lock index 6e1f650c4..71187718f 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -1468,6 +1468,11 @@ dependencies: "@types/node" "*" +"@types/parse-link-header@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-2.0.3.tgz#37ad650d12aecb055b64c2d43ddb1534e356ad33" + integrity sha512-ffLAxD6Xqcf2gSbtEJehj8yJ5R/2OZqD4liodQvQQ+hhO4kg1mk9ToEZQPMtNTm/zIQj2GNleQbsjPp9+UQm4Q== + "@types/prop-types@*": version "15.7.8" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3" @@ -5182,6 +5187,13 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" +parse-link-header@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-2.0.0.tgz#949353e284f8aa01f2ac857a98f692b57733f6b7" + integrity sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw== + dependencies: + xtend "~4.0.1" + parse-ms@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" |