mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-10-31 22:40:01 +00:00
[feature] Page through accounts as moderator (#2881)
* [feature] Page through accounts as moderator * aaaaa * use COLLATE "C" for Postgres to ensure same ordering as SQLite * fix typo, test paging up * don't show moderation / info for our instance acct
This commit is contained in:
parent
1edcb06afe
commit
725a21b027
30 changed files with 1473 additions and 432 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
546
internal/api/client/admin/accountsgetv2_test.go
Normal file
546
internal/api/client/admin/accountsgetv2_test.go
Normal file
|
@ -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{})
|
||||
}
|
|
@ -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.
|
||||
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"),
|
||||
)
|
||||
} else {
|
||||
// Create a subquery for
|
||||
// Postgres to reuse.
|
||||
subQ = a.db.NewRaw(
|
||||
"(COALESCE(?, ?) || ? || ?) COLLATE ?",
|
||||
bun.Ident("domain"), "",
|
||||
"/@",
|
||||
bun.Ident("username"),
|
||||
bun.Ident("C"),
|
||||
)
|
||||
}
|
||||
|
||||
// Return only accounts with `[domain]/@[username]`
|
||||
// later in the alphabet (a-z) than provided maxID.
|
||||
if maxID != "" {
|
||||
maxIDAcct, err := a.GetAccountByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
maxID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting maxID account %s: %w", maxID, err)
|
||||
if dbDialect == dialect.SQLite {
|
||||
// Use aliased column.
|
||||
q = q.Where("? > ?", bun.Ident("domain_username"), maxID)
|
||||
} else {
|
||||
q = q.Where("? > ?", subQ, maxID)
|
||||
}
|
||||
}
|
||||
|
||||
q = q.Where("? < ?", bun.Ident("account.created_at"), maxIDAcct.CreatedAt)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -115,8 +115,12 @@ func() bool { return request.Permissions == "staff" }(),
|
|||
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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
113
web/source/settings/components/pageable-list.tsx
Normal file
113
web/source/settings/components/pageable-list.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
|
||||
if (user.suspended) {
|
||||
let className = "username-lozenge";
|
||||
let isLocal = account.domain == null;
|
||||
|
||||
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 (
|
|
@ -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.
|
||||
Ensure user ends up somewhere
|
||||
if they just open /settings.
|
||||
*/}
|
||||
<Route><Redirect to="/user/profile" /></Route>
|
||||
<Route path="/"><Redirect to="/user" /></Route>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</section>
|
||||
|
|
|
@ -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' }],
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,15 +1119,15 @@ button.with-padding {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
.username-lozenge {
|
||||
line-height: 1.3rem;
|
||||
display: inline-block;
|
||||
background: $fg-accent;
|
||||
color: $bg;
|
||||
border-radius: $br;
|
||||
padding: 0.15rem 0.15rem;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0.15rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
|
||||
|
@ -1124,23 +1145,32 @@ button.with-padding {
|
|||
background: $green1;
|
||||
}
|
||||
}
|
||||
|
||||
.spanlink {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.accounts-view {
|
||||
form {
|
||||
margin-bottom: 1rem;
|
||||
.pageable-list {
|
||||
.username-lozenge {
|
||||
line-height: inherit;
|
||||
color: $fg;
|
||||
font-weight: initial;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
background: $list-entry-bg;
|
||||
|
||||
.fa {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0.5rem 0;
|
||||
&:nth-child(even) {
|
||||
background: $list-entry-alternate-bg;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $fg;
|
||||
text-decoration: none;
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,14 +27,42 @@ 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")
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -23,45 +23,83 @@ 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";
|
||||
};
|
||||
// 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralAccountDetails({ adminAcct } : { adminAcct: AdminAccount }) {
|
||||
const local = !adminAcct.domain;
|
||||
const created = new Date(adminAcct.created_at).toDateString();
|
||||
|
||||
let 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">
|
||||
|
@ -75,6 +113,18 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
|
|||
<dt>Domain</dt>
|
||||
<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>
|
||||
|
@ -104,9 +154,12 @@ 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!
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalAccountDetails({ adminAcct }: { adminAcct: AdminAccount }) {
|
||||
return (
|
||||
<>
|
||||
<h3>Local Account Details</h3>
|
||||
{ !adminAcct.approved &&
|
||||
|
@ -149,16 +202,6 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
|
|||
<dd>{adminAcct.locale}</dd>
|
||||
</div> }
|
||||
</dl>
|
||||
</> }
|
||||
{ local && !adminAcct.approved
|
||||
?
|
||||
<HandleSignup
|
||||
account={adminAcct}
|
||||
backLocation={backLocation}
|
||||
/>
|
||||
:
|
||||
<AccountActions account={adminAcct} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue