summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/api/swagger.yaml28
-rw-r--r--internal/api/client/admin/accountsgetv1.go29
-rw-r--r--internal/api/client/admin/accountsgetv2.go29
-rw-r--r--internal/api/client/admin/accountsgetv2_test.go546
-rw-r--r--internal/api/client/statuses/statushistory_test.go4
-rw-r--r--internal/db/bundb/account.go115
-rw-r--r--internal/db/bundb/account_test.go74
-rw-r--r--internal/db/bundb/migrations/20240426122821_pageable_admin_accounts.go84
-rw-r--r--internal/processing/admin/accounts.go8
-rw-r--r--web/source/package.json2
-rw-r--r--web/source/settings/components/account-list.tsx82
-rw-r--r--web/source/settings/components/pageable-list.tsx113
-rw-r--r--web/source/settings/components/username.tsx (renamed from web/source/settings/views/moderation/reports/username.tsx)48
-rw-r--r--web/source/settings/index.tsx9
-rw-r--r--web/source/settings/lib/query/admin/index.ts13
-rw-r--r--web/source/settings/lib/query/gts-api.ts6
-rw-r--r--web/source/settings/lib/types/account.ts6
-rw-r--r--web/source/settings/style.css109
-rw-r--r--web/source/settings/views/moderation/accounts/detail/actions.tsx126
-rw-r--r--web/source/settings/views/moderation/accounts/detail/handlesignup.tsx114
-rw-r--r--web/source/settings/views/moderation/accounts/detail/index.tsx157
-rw-r--r--web/source/settings/views/moderation/accounts/detail/util.tsx43
-rw-r--r--web/source/settings/views/moderation/accounts/index.tsx4
-rw-r--r--web/source/settings/views/moderation/accounts/pending/index.tsx28
-rw-r--r--web/source/settings/views/moderation/accounts/search/index.tsx80
-rw-r--r--web/source/settings/views/moderation/menu.tsx8
-rw-r--r--web/source/settings/views/moderation/reports/detail.tsx12
-rw-r--r--web/source/settings/views/moderation/reports/overview.tsx4
-rw-r--r--web/source/settings/views/moderation/router.tsx10
-rw-r--r--web/source/yarn.lock12
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>
+ &nbsp;
+ <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"