[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
|
@ -3774,11 +3774,13 @@ paths:
|
||||||
/api/v1/admin/accounts:
|
/api/v1/admin/accounts:
|
||||||
get:
|
get:
|
||||||
description: |-
|
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.
|
The next and previous queries can be parsed from the returned Link header.
|
||||||
Example:
|
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
|
operationId: adminAccountsGetV1
|
||||||
parameters:
|
parameters:
|
||||||
|
@ -3847,19 +3849,15 @@ paths:
|
||||||
in: query
|
in: query
|
||||||
name: staff
|
name: staff
|
||||||
type: boolean
|
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
|
in: query
|
||||||
name: max_id
|
name: max_id
|
||||||
type: string
|
type: string
|
||||||
- description: All results returned will be 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: since_id
|
|
||||||
type: string
|
|
||||||
- description: Returns results immediately newer than the item with this ID.
|
|
||||||
in: query
|
in: query
|
||||||
name: min_id
|
name: min_id
|
||||||
type: string
|
type: string
|
||||||
- default: 100
|
- default: 50
|
||||||
description: Maximum number of results to return.
|
description: Maximum number of results to return.
|
||||||
in: query
|
in: query
|
||||||
maximum: 200
|
maximum: 200
|
||||||
|
@ -8463,11 +8461,13 @@ paths:
|
||||||
/api/v2/admin/accounts:
|
/api/v2/admin/accounts:
|
||||||
get:
|
get:
|
||||||
description: |-
|
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.
|
The next and previous queries can be parsed from the returned Link header.
|
||||||
Example:
|
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
|
operationId: adminAccountsGetV2
|
||||||
parameters:
|
parameters:
|
||||||
|
@ -8513,19 +8513,15 @@ paths:
|
||||||
in: query
|
in: query
|
||||||
name: ip
|
name: ip
|
||||||
type: string
|
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
|
in: query
|
||||||
name: max_id
|
name: max_id
|
||||||
type: string
|
type: string
|
||||||
- description: All results returned will be 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: since_id
|
|
||||||
type: string
|
|
||||||
- description: Returns results immediately newer than the item with this ID.
|
|
||||||
in: query
|
in: query
|
||||||
name: min_id
|
name: min_id
|
||||||
type: string
|
type: string
|
||||||
- default: 100
|
- default: 50
|
||||||
description: Maximum number of results to return.
|
description: Maximum number of results to return.
|
||||||
in: query
|
in: query
|
||||||
maximum: 200
|
maximum: 200
|
||||||
|
|
|
@ -19,11 +19,13 @@
|
||||||
//
|
//
|
||||||
// View + page through known accounts according to given filters.
|
// 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.
|
// The next and previous queries can be parsed from the returned Link header.
|
||||||
// Example:
|
// 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
|
// name: max_id
|
||||||
// in: query
|
// in: query
|
||||||
// type: string
|
// type: string
|
||||||
// description: All results returned will be older than the item with this ID.
|
// description: >-
|
||||||
// -
|
// max_id in the form `[domain]/@[username]`.
|
||||||
// name: since_id
|
// All results returned will be later in the alphabet than `[domain]/@[username]`.
|
||||||
// in: query
|
// For example, if max_id = `example.org/@someone` then returned entries might
|
||||||
// type: string
|
// contain `example.org/@someone_else`, `later.example.org/@someone`, etc.
|
||||||
// description: All results returned will be newer than the item with this ID.
|
// 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
|
// name: min_id
|
||||||
// in: query
|
// in: query
|
||||||
// type: string
|
// 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
|
// name: limit
|
||||||
// in: query
|
// in: query
|
||||||
// type: integer
|
// type: integer
|
||||||
// description: Maximum number of results to return.
|
// description: Maximum number of results to return.
|
||||||
// default: 100
|
// default: 50
|
||||||
// maximum: 200
|
// maximum: 200
|
||||||
// minimum: 1
|
// minimum: 1
|
||||||
//
|
//
|
||||||
|
@ -200,7 +209,7 @@ func (m *Module) AccountsGETV1Handler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
page, errWithCode := paging.ParseIDPage(c, 1, 200, 100)
|
page, errWithCode := paging.ParseIDPage(c, 1, 200, 50)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -19,11 +19,13 @@
|
||||||
//
|
//
|
||||||
// View + page through known accounts according to given filters.
|
// 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.
|
// The next and previous queries can be parsed from the returned Link header.
|
||||||
// Example:
|
// 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
|
// name: max_id
|
||||||
// in: query
|
// in: query
|
||||||
// type: string
|
// type: string
|
||||||
// description: All results returned will be older than the item with this ID.
|
// description: >-
|
||||||
// -
|
// max_id in the form `[domain]/@[username]`.
|
||||||
// name: since_id
|
// All results returned will be later in the alphabet than `[domain]/@[username]`.
|
||||||
// in: query
|
// For example, if max_id = `example.org/@someone` then returned entries might
|
||||||
// type: string
|
// contain `example.org/@someone_else`, `later.example.org/@someone`, etc.
|
||||||
// description: All results returned will be newer than the item with this ID.
|
// 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
|
// name: min_id
|
||||||
// in: query
|
// in: query
|
||||||
// type: string
|
// 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
|
// name: limit
|
||||||
// in: query
|
// in: query
|
||||||
// type: integer
|
// type: integer
|
||||||
// description: Maximum number of results to return.
|
// description: Maximum number of results to return.
|
||||||
// default: 100
|
// default: 50
|
||||||
// maximum: 200
|
// maximum: 200
|
||||||
// minimum: 1
|
// minimum: 1
|
||||||
//
|
//
|
||||||
|
@ -173,7 +182,7 @@ func (m *Module) AccountsGETV2Handler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
page, errWithCode := paging.ParseIDPage(c, 1, 200, 100)
|
page, errWithCode := paging.ParseIDPage(c, 1, 200, 50)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -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)
|
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(
|
func (a *accountDB) GetAccounts(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
origin string,
|
origin string,
|
||||||
|
@ -269,6 +295,11 @@ func (a *accountDB) GetAccounts(
|
||||||
error,
|
error,
|
||||||
) {
|
) {
|
||||||
var (
|
var (
|
||||||
|
// We have to use different
|
||||||
|
// syntax for this query
|
||||||
|
// depending on dialect.
|
||||||
|
dbDialect = a.db.Dialect().Name()
|
||||||
|
|
||||||
// local users lists,
|
// local users lists,
|
||||||
// required for some
|
// required for some
|
||||||
// limiting parameters.
|
// limiting parameters.
|
||||||
|
@ -287,10 +318,6 @@ func (a *accountDB) GetAccounts(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get paging params.
|
// 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()
|
minID = page.GetMin()
|
||||||
maxID = page.GetMax()
|
maxID = page.GetMax()
|
||||||
limit = page.GetLimit()
|
limit = page.GetLimit()
|
||||||
|
@ -309,32 +336,50 @@ func (a *accountDB) GetAccounts(
|
||||||
// Select only IDs from table
|
// Select only IDs from table
|
||||||
Column("account.id")
|
Column("account.id")
|
||||||
|
|
||||||
// Return only accounts OLDER
|
var subQ *bun.RawQuery
|
||||||
// than account with maxID.
|
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 != "" {
|
if maxID != "" {
|
||||||
maxIDAcct, err := a.GetAccountByID(
|
if dbDialect == dialect.SQLite {
|
||||||
gtscontext.SetBarebones(ctx),
|
// Use aliased column.
|
||||||
maxID,
|
q = q.Where("? > ?", bun.Ident("domain_username"), maxID)
|
||||||
)
|
} else {
|
||||||
if err != nil {
|
q = q.Where("? > ?", subQ, maxID)
|
||||||
return nil, fmt.Errorf("error getting maxID account %s: %w", maxID, err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
q = q.Where("? < ?", bun.Ident("account.created_at"), maxIDAcct.CreatedAt)
|
// Return only accounts with `[domain]/@[username]`
|
||||||
}
|
// earlier in the alphabet (a-z) than provided minID.
|
||||||
|
|
||||||
// Return only accounts NEWER
|
|
||||||
// than account with minID.
|
|
||||||
if minID != "" {
|
if minID != "" {
|
||||||
minIDAcct, err := a.GetAccountByID(
|
if dbDialect == dialect.SQLite {
|
||||||
gtscontext.SetBarebones(ctx),
|
// Use aliased column.
|
||||||
minID,
|
q = q.Where("? < ?", bun.Ident("domain_username"), minID)
|
||||||
)
|
} else {
|
||||||
if err != nil {
|
q = q.Where("? < ?", subQ, minID)
|
||||||
return nil, fmt.Errorf("error getting minID account %s: %w", minID, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
|
@ -479,13 +524,29 @@ func (a *accountDB) GetAccounts(
|
||||||
|
|
||||||
if order == paging.OrderAscending {
|
if order == paging.OrderAscending {
|
||||||
// Page up.
|
// 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 {
|
} else {
|
||||||
// Page down.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -502,6 +502,80 @@ func (suite *AccountTestSuite) TestGetAccountsAll() {
|
||||||
suite.Len(accounts, 9)
|
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() {
|
func (suite *AccountTestSuite) TestGetAccountsModsOnly() {
|
||||||
var (
|
var (
|
||||||
ctx = context.Background()
|
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 (p *Processor) AccountsGet(
|
||||||
return paging.EmptyResponse(), nil
|
return paging.EmptyResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hi := accounts[count-1].ID
|
var (
|
||||||
lo := accounts[0].ID
|
loAcct = accounts[count-1]
|
||||||
|
hiAcct = accounts[0]
|
||||||
|
lo = loAcct.Domain + "/@" + loAcct.Username
|
||||||
|
hi = hiAcct.Domain + "/@" + hiAcct.Username
|
||||||
|
)
|
||||||
|
|
||||||
items := make([]interface{}, 0, count)
|
items := make([]interface{}, 0, count)
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"nanoid": "^4.0.0",
|
"nanoid": "^4.0.0",
|
||||||
"object-to-formdata": "^4.4.2",
|
"object-to-formdata": "^4.4.2",
|
||||||
"papaparse": "^5.3.2",
|
"papaparse": "^5.3.2",
|
||||||
|
"parse-link-header": "^2.0.0",
|
||||||
"photoswipe": "^5.3.3",
|
"photoswipe": "^5.3.3",
|
||||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
||||||
"plyr": "^3.7.8",
|
"plyr": "^3.7.8",
|
||||||
|
@ -44,6 +45,7 @@
|
||||||
"@joepie91/eslint-config": "^1.1.1",
|
"@joepie91/eslint-config": "^1.1.1",
|
||||||
"@types/is-valid-domain": "^0.0.2",
|
"@types/is-valid-domain": "^0.0.2",
|
||||||
"@types/papaparse": "^5.3.9",
|
"@types/papaparse": "^5.3.9",
|
||||||
|
"@types/parse-link-header": "^2.0.3",
|
||||||
"@types/psl": "^1.1.1",
|
"@types/psl": "^1.1.1",
|
||||||
"@types/react-dom": "^18.2.8",
|
"@types/react-dom": "^18.2.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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 React from "react";
|
||||||
import { Link } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { AdminAccount } from "../../../lib/types/account";
|
import { AdminAccount } from "../lib/types/account";
|
||||||
|
|
||||||
interface UsernameProps {
|
interface UsernameProps {
|
||||||
user: AdminAccount;
|
account: AdminAccount;
|
||||||
link?: string;
|
linkTo?: string;
|
||||||
|
backLocation?: string;
|
||||||
|
classNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Username({ user, link }: UsernameProps) {
|
export default function Username({ account, linkTo, backLocation, classNames }: UsernameProps) {
|
||||||
let className = "user";
|
const [ _location, setLocation ] = useLocation();
|
||||||
let isLocal = user.domain == null;
|
|
||||||
|
|
||||||
if (user.suspended) {
|
let className = "username-lozenge";
|
||||||
|
let isLocal = account.domain == null;
|
||||||
|
|
||||||
|
if (account.suspended) {
|
||||||
className += " suspended";
|
className += " suspended";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,23 +42,43 @@ export default function Username({ user, link }: UsernameProps) {
|
||||||
className += " local";
|
className += " local";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (classNames) {
|
||||||
|
className = [ className, classNames ].flat().join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
let icon = isLocal
|
let icon = isLocal
|
||||||
? { fa: "fa-home", info: "Local user" }
|
? { fa: "fa-home", info: "Local user" }
|
||||||
: { fa: "fa-external-link-square", info: "Remote user" };
|
: { fa: "fa-external-link-square", info: "Remote user" };
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<span className="acct">@{user.account.acct}</span>
|
|
||||||
<i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} />
|
<i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} />
|
||||||
<span className="sr-only">{icon.info}</span>
|
<span className="sr-only">{icon.info}</span>
|
||||||
|
|
||||||
|
<span className="acct">@{account.account.acct}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (link) {
|
if (linkTo) {
|
||||||
|
className += " spanlink";
|
||||||
return (
|
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}
|
{content}
|
||||||
</Link>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
|
@ -59,11 +59,10 @@ export function App({ account }: AppProps) {
|
||||||
<ModerationRouter />
|
<ModerationRouter />
|
||||||
<AdminRouter />
|
<AdminRouter />
|
||||||
{/*
|
{/*
|
||||||
Redirect to first part of UserRouter if
|
Ensure user ends up somewhere
|
||||||
just the bare settings page is open, so
|
if they just open /settings.
|
||||||
user isn't greeted with a blank page.
|
|
||||||
*/}
|
*/}
|
||||||
<Route><Redirect to="/user/profile" /></Route>
|
<Route path="/"><Redirect to="/user" /></Route>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Router>
|
</Router>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -20,8 +20,9 @@
|
||||||
import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers";
|
import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers";
|
||||||
import { gtsApi } from "../gts-api";
|
import { gtsApi } from "../gts-api";
|
||||||
import { listToKeyedObject } from "../transforms";
|
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 { InstanceRule, MappedRules } from "../../types/rules";
|
||||||
|
import parse from "parse-link-header";
|
||||||
|
|
||||||
const extended = gtsApi.injectEndpoints({
|
const extended = gtsApi.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
|
@ -65,7 +66,7 @@ const extended = gtsApi.injectEndpoints({
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
searchAccounts: build.query<AdminAccount[], SearchAccountParams>({
|
searchAccounts: build.query<SearchAccountResp, SearchAccountParams>({
|
||||||
query: (form) => {
|
query: (form) => {
|
||||||
const params = new(URLSearchParams);
|
const params = new(URLSearchParams);
|
||||||
Object.entries(form).forEach(([k, v]) => {
|
Object.entries(form).forEach(([k, v]) => {
|
||||||
|
@ -83,10 +84,16 @@ const extended = gtsApi.injectEndpoints({
|
||||||
url: `/api/v2/admin/accounts${query}`
|
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) =>
|
providesTags: (res) =>
|
||||||
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' },
|
||||||
]
|
]
|
||||||
: [{ type: 'Account', id: 'LIST' }],
|
: [{ type: 'Account', id: 'LIST' }],
|
||||||
|
|
|
@ -24,7 +24,7 @@ import type {
|
||||||
FetchBaseQueryError,
|
FetchBaseQueryError,
|
||||||
} from '@reduxjs/toolkit/query/react';
|
} from '@reduxjs/toolkit/query/react';
|
||||||
import { serialize as serializeForm } from "object-to-formdata";
|
import { serialize as serializeForm } from "object-to-formdata";
|
||||||
|
import type { FetchBaseQueryMeta } from "@reduxjs/toolkit/dist/query/fetchBaseQuery";
|
||||||
import type { RootState } from '../../redux/store';
|
import type { RootState } from '../../redux/store';
|
||||||
import { InstanceV1 } from '../types/instance';
|
import { InstanceV1 } from '../types/instance';
|
||||||
|
|
||||||
|
@ -65,7 +65,9 @@ export interface GTSFetchArgs extends FetchArgs {
|
||||||
const gtsBaseQuery: BaseQueryFn<
|
const gtsBaseQuery: BaseQueryFn<
|
||||||
string | GTSFetchArgs,
|
string | GTSFetchArgs,
|
||||||
any,
|
any,
|
||||||
FetchBaseQueryError
|
FetchBaseQueryError,
|
||||||
|
{},
|
||||||
|
FetchBaseQueryMeta
|
||||||
> = async (args, api, extraOptions) => {
|
> = async (args, api, extraOptions) => {
|
||||||
// Retrieve state at the moment
|
// Retrieve state at the moment
|
||||||
// this function was called.
|
// this function was called.
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Links } from "parse-link-header";
|
||||||
import { CustomEmoji } from "./custom-emoji";
|
import { CustomEmoji } from "./custom-emoji";
|
||||||
|
|
||||||
export interface AdminAccount {
|
export interface AdminAccount {
|
||||||
|
@ -79,6 +80,11 @@ export interface SearchAccountParams {
|
||||||
limit?: number,
|
limit?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchAccountResp {
|
||||||
|
accounts: AdminAccount[];
|
||||||
|
links: Links | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HandleSignupParams {
|
export interface HandleSignupParams {
|
||||||
id: string,
|
id: string,
|
||||||
approve_or_reject: "approve" | "reject",
|
approve_or_reject: "approve" | "reject",
|
||||||
|
|
|
@ -16,6 +16,11 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
This source file uses PostCSS syntax.
|
||||||
|
See: https://postcss.org/
|
||||||
|
*/
|
||||||
|
|
||||||
body {
|
body {
|
||||||
grid-template-rows: auto 1fr;
|
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 {
|
.domain-permissions-list {
|
||||||
p {
|
p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
@ -1098,15 +1119,15 @@ button.with-padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.user {
|
.username-lozenge {
|
||||||
line-height: 1.3rem;
|
line-height: 1.3rem;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: $fg-accent;
|
background: $fg-accent;
|
||||||
color: $bg;
|
color: $bg;
|
||||||
border-radius: $br;
|
border-radius: $br;
|
||||||
padding: 0.15rem 0.15rem;
|
padding: 0.15rem;
|
||||||
margin: 0 0.1rem;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
|
@ -1124,23 +1145,32 @@ button.with-padding {
|
||||||
background: $green1;
|
background: $green1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spanlink {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accounts-view {
|
.accounts-view {
|
||||||
form {
|
.pageable-list {
|
||||||
margin-bottom: 1rem;
|
.username-lozenge {
|
||||||
|
line-height: inherit;
|
||||||
|
color: $fg;
|
||||||
|
font-weight: initial;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
background: $list-entry-bg;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
&:nth-child(even) {
|
||||||
margin: 0.5rem 0;
|
background: $list-entry-alternate-bg;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
.acct {
|
||||||
color: $fg;
|
color: var(--link-fg);
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
#username {
|
|
||||||
color: $link-fg;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1154,6 +1184,7 @@ button.with-padding {
|
||||||
.profile {
|
.profile {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 60rem;
|
max-width: 60rem;
|
||||||
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h4, h3, h2 {
|
h4, h3, h2 {
|
||||||
|
@ -1185,6 +1216,16 @@ button.with-padding {
|
||||||
dd {
|
dd {
|
||||||
word-break: break-word;
|
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 React from "react";
|
||||||
|
|
||||||
import { useActionAccountMutation } from "../../../../lib/query/admin";
|
import { useActionAccountMutation, useHandleSignupMutation } from "../../../../lib/query/admin";
|
||||||
import MutationButton from "../../../../components/form/mutation-button";
|
import MutationButton from "../../../../components/form/mutation-button";
|
||||||
import useFormSubmit from "../../../../lib/form/submit";
|
import useFormSubmit from "../../../../lib/form/submit";
|
||||||
import {
|
import {
|
||||||
|
@ -27,14 +27,42 @@ import {
|
||||||
useTextInput,
|
useTextInput,
|
||||||
useBoolInput,
|
useBoolInput,
|
||||||
} from "../../../../lib/form";
|
} 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 { AdminAccount } from "../../../../lib/types/account";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
export interface AccountActionsProps {
|
export interface AccountActionsProps {
|
||||||
account: AdminAccount,
|
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 = {
|
const form = {
|
||||||
id: useValue("id", account.id),
|
id: useValue("id", account.id),
|
||||||
reason: useTextInput("text")
|
reason: useTextInput("text")
|
||||||
|
@ -60,16 +88,6 @@ export function AccountActions({ account }: AccountActionsProps) {
|
||||||
placeholder="Reason for this action"
|
placeholder="Reason for this action"
|
||||||
/>
|
/>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
{/* <MutationButton
|
|
||||||
label="Disable"
|
|
||||||
name="disable"
|
|
||||||
result={result}
|
|
||||||
/>
|
|
||||||
<MutationButton
|
|
||||||
label="Silence"
|
|
||||||
name="silence"
|
|
||||||
result={result}
|
|
||||||
/> */}
|
|
||||||
<MutationButton
|
<MutationButton
|
||||||
disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
|
disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
|
||||||
label="Suspend"
|
label="Suspend"
|
||||||
|
@ -84,3 +102,81 @@ export function AccountActions({ account }: AccountActionsProps) {
|
||||||
</form>
|
</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 FormWithData from "../../../../lib/form/form-with-data";
|
||||||
import FakeProfile from "../../../../components/fake-profile";
|
import FakeProfile from "../../../../components/fake-profile";
|
||||||
import { AdminAccount } from "../../../../lib/types/account";
|
import { AdminAccount } from "../../../../lib/types/account";
|
||||||
import { HandleSignup } from "./handlesignup";
|
|
||||||
import { AccountActions } from "./actions";
|
import { AccountActions } from "./actions";
|
||||||
import { useParams } from "wouter";
|
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() {
|
export default function AccountDetail() {
|
||||||
const params: { accountID: string } = useParams();
|
const params: { accountID: string } = useParams();
|
||||||
|
const baseUrl = useBaseUrl();
|
||||||
|
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="account-detail">
|
<div className="account-detail">
|
||||||
<h1>Account Details</h1>
|
<h1><BackButton to={backLocation} /> Account Details</h1>
|
||||||
<FormWithData
|
<FormWithData
|
||||||
dataQuery={useGetAccountQuery}
|
dataQuery={useGetAccountQuery}
|
||||||
queryArg={params.accountID}
|
queryArg={params.accountID}
|
||||||
DataForm={AccountDetailForm}
|
DataForm={AccountDetailForm}
|
||||||
|
{...{ backLocation: backLocation }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountDetailFormProps {
|
interface AccountDetailFormProps {
|
||||||
backLocation: string,
|
data: AdminAccount;
|
||||||
data: AdminAccount,
|
backLocation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
|
function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
|
||||||
let yesOrNo = (b: boolean) => {
|
// If this is our instance account, don't
|
||||||
return b ? "yes" : "no";
|
// 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";
|
let lastPosted = "never";
|
||||||
if (adminAcct.account.last_status_at) {
|
if (adminAcct.account.last_status_at) {
|
||||||
lastPosted = new Date(adminAcct.account.last_status_at).toDateString();
|
lastPosted = new Date(adminAcct.account.last_status_at).toDateString();
|
||||||
}
|
}
|
||||||
const local = !adminAcct.domain;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FakeProfile {...adminAcct.account} />
|
|
||||||
<h3>General Account Details</h3>
|
<h3>General Account Details</h3>
|
||||||
{ adminAcct.suspended &&
|
{ adminAcct.suspended &&
|
||||||
<div className="info">
|
<div className="info">
|
||||||
|
@ -75,6 +113,18 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
|
||||||
<dt>Domain</dt>
|
<dt>Domain</dt>
|
||||||
<dd>{adminAcct.domain}</dd>
|
<dd>{adminAcct.domain}</dd>
|
||||||
</div>}
|
</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">
|
<div className="info-list-entry">
|
||||||
<dt>Created</dt>
|
<dt>Created</dt>
|
||||||
<dd><time dateTime={adminAcct.created_at}>{created}</time></dd>
|
<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>
|
<dd>{adminAcct.account.following_count}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{ local &&
|
</>
|
||||||
// Only show local account details
|
);
|
||||||
// if this is a local account!
|
}
|
||||||
|
|
||||||
|
function LocalAccountDetails({ adminAcct }: { adminAcct: AdminAccount }) {
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<h3>Local Account Details</h3>
|
<h3>Local Account Details</h3>
|
||||||
{ !adminAcct.approved &&
|
{ !adminAcct.approved &&
|
||||||
|
@ -149,16 +202,6 @@ function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormP
|
||||||
<dd>{adminAcct.locale}</dd>
|
<dd>{adminAcct.locale}</dd>
|
||||||
</div> }
|
</div> }
|
||||||
</dl>
|
</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 React from "react";
|
||||||
import { AccountSearchForm } from "./search";
|
import { AccountSearchForm } from "./search";
|
||||||
|
|
||||||
export default function AccountsOverview({ }) {
|
export default function AccountsSearch({ }) {
|
||||||
return (
|
return (
|
||||||
<div className="accounts-view">
|
<div className="accounts-view">
|
||||||
<h1>Accounts Overview</h1>
|
<h1>Accounts Search</h1>
|
||||||
<span>
|
<span>
|
||||||
You can perform actions on an account by clicking
|
You can perform actions on an account by clicking
|
||||||
its name in a report, or by searching for the account
|
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/>.
|
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 { 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() {
|
export default function AccountsPending() {
|
||||||
|
const [ location, _setLocation ] = useLocation();
|
||||||
const searchRes = useSearchAccountsQuery({status: "pending"});
|
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 (
|
return (
|
||||||
<div className="accounts-view">
|
<div className="accounts-view">
|
||||||
<h1>Pending Accounts</h1>
|
<h1>Pending Accounts</h1>
|
||||||
<AccountList
|
<PageableList
|
||||||
isLoading={searchRes.isLoading}
|
isLoading={searchRes.isLoading}
|
||||||
|
isFetching={searchRes.isFetching}
|
||||||
isSuccess={searchRes.isSuccess}
|
isSuccess={searchRes.isSuccess}
|
||||||
data={searchRes.data}
|
items={searchRes.data?.accounts}
|
||||||
|
itemToEntry={itemToEntry}
|
||||||
isError={searchRes.isError}
|
isError={searchRes.isError}
|
||||||
error={searchRes.error}
|
error={searchRes.error}
|
||||||
emptyMessage="No pending account sign-ups."
|
emptyMessage="No pending account sign-ups."
|
||||||
|
|
|
@ -17,28 +17,53 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
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 { useLazySearchAccountsQuery } from "../../../../lib/query/admin";
|
||||||
import { useTextInput } from "../../../../lib/form";
|
import { useTextInput } from "../../../../lib/form";
|
||||||
import { AccountList } from "../../../../components/account-list";
|
import { PageableList } from "../../../../components/pageable-list";
|
||||||
import { SearchAccountParams } from "../../../../lib/types/account";
|
|
||||||
import { Select, TextInput } from "../../../../components/form/inputs";
|
import { Select, TextInput } from "../../../../components/form/inputs";
|
||||||
import MutationButton from "../../../../components/form/mutation-button";
|
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() {
|
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 = {
|
const form = {
|
||||||
origin: useTextInput("origin"),
|
origin: useTextInput("origin", { defaultValue: urlQueryParams.get("origin") ?? ""}),
|
||||||
status: useTextInput("status"),
|
status: useTextInput("status", { defaultValue: urlQueryParams.get("status") ?? ""}),
|
||||||
permissions: useTextInput("permissions"),
|
permissions: useTextInput("permissions", { defaultValue: urlQueryParams.get("permissions") ?? ""}),
|
||||||
username: useTextInput("username"),
|
username: useTextInput("username", { defaultValue: urlQueryParams.get("username") ?? ""}),
|
||||||
display_name: useTextInput("display_name"),
|
display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}),
|
||||||
by_domain: useTextInput("by_domain"),
|
by_domain: useTextInput("by_domain", { defaultValue: urlQueryParams.get("by_domain") ?? ""}),
|
||||||
email: useTextInput("email"),
|
email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}),
|
||||||
ip: useTextInput("ip"),
|
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();
|
e.preventDefault();
|
||||||
|
|
||||||
// Parse query parameters.
|
// Parse query parameters.
|
||||||
|
@ -52,16 +77,32 @@ export function AccountSearchForm() {
|
||||||
// Remove any nulls.
|
// Remove any nulls.
|
||||||
return kv || [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<form
|
<form
|
||||||
onSubmit={submitSearch}
|
onSubmit={submitQuery}
|
||||||
// Prevent password managers trying
|
// Prevent password managers trying
|
||||||
// to fill in username/email fields.
|
// to fill in username/email fields.
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
@ -117,13 +158,16 @@ export function AccountSearchForm() {
|
||||||
result={searchRes}
|
result={searchRes}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<AccountList
|
<PageableList
|
||||||
isLoading={searchRes.isLoading}
|
isLoading={searchRes.isLoading}
|
||||||
|
isFetching={searchRes.isFetching}
|
||||||
isSuccess={searchRes.isSuccess}
|
isSuccess={searchRes.isSuccess}
|
||||||
data={searchRes.data}
|
items={searchRes.data?.accounts}
|
||||||
|
itemToEntry={itemToEntry}
|
||||||
isError={searchRes.isError}
|
isError={searchRes.isError}
|
||||||
error={searchRes.error}
|
error={searchRes.error}
|
||||||
emptyMessage="No accounts found that match your query"
|
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/overview
|
||||||
* - /settings/moderation/reports/:reportId
|
* - /settings/moderation/reports/:reportId
|
||||||
* - /settings/moderation/accounts/overview
|
* - /settings/moderation/accounts/search
|
||||||
* - /settings/moderation/accounts/pending
|
* - /settings/moderation/accounts/pending
|
||||||
* - /settings/moderation/accounts/:accountID
|
* - /settings/moderation/accounts/:accountID
|
||||||
* - /settings/moderation/domain-permissions/:permType
|
* - /settings/moderation/domain-permissions/:permType
|
||||||
|
@ -76,12 +76,12 @@ function ModerationAccountsMenu() {
|
||||||
<MenuItem
|
<MenuItem
|
||||||
name="Accounts"
|
name="Accounts"
|
||||||
itemUrl="accounts"
|
itemUrl="accounts"
|
||||||
defaultChild="overview"
|
defaultChild="search"
|
||||||
icon="fa-users"
|
icon="fa-users"
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
name="Overview"
|
name="Search"
|
||||||
itemUrl="overview"
|
itemUrl="search"
|
||||||
icon="fa-list"
|
icon="fa-list"
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form";
|
||||||
import useFormSubmit from "../../../lib/form/submit";
|
import useFormSubmit from "../../../lib/form/submit";
|
||||||
import { TextArea } from "../../../components/form/inputs";
|
import { TextArea } from "../../../components/form/inputs";
|
||||||
import MutationButton from "../../../components/form/mutation-button";
|
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 { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
|
||||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||||
|
|
||||||
|
@ -53,13 +53,15 @@ function ReportDetailForm({ data: report }) {
|
||||||
<div className="report detail">
|
<div className="report detail">
|
||||||
<div className="usernames">
|
<div className="usernames">
|
||||||
<Username
|
<Username
|
||||||
user={from}
|
account={from}
|
||||||
link={`~/settings/moderation/accounts/${from.id}`}
|
linkTo={`~/settings/moderation/accounts/${from.id}`}
|
||||||
|
backLocation={`~/settings/moderation/reports/${report.id}`}
|
||||||
/>
|
/>
|
||||||
<> reported </>
|
<> reported </>
|
||||||
<Username
|
<Username
|
||||||
user={target}
|
account={target}
|
||||||
link={`~/settings/moderation/accounts/${target.id}`}
|
linkTo={`~/settings/moderation/accounts/${target.id}`}
|
||||||
|
backLocation={`~/settings/moderation/reports/${report.id}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import FormWithData from "../../../lib/form/form-with-data";
|
import FormWithData from "../../../lib/form/form-with-data";
|
||||||
import Username from "./username";
|
import Username from "../../../components/username";
|
||||||
import { useListReportsQuery } from "../../../lib/query/admin/reports";
|
import { useListReportsQuery } from "../../../lib/query/admin/reports";
|
||||||
|
|
||||||
export function ReportOverview({ }) {
|
export function ReportOverview({ }) {
|
||||||
|
@ -75,7 +75,7 @@ function ReportEntry({ report }) {
|
||||||
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
||||||
<div className="byline">
|
<div className="byline">
|
||||||
<div className="usernames">
|
<div className="usernames">
|
||||||
<Username user={from} /> reported <Username user={target} />
|
<Username account={from} /> reported <Username account={target} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="report-status">
|
<h3 className="report-status">
|
||||||
{report.action_taken ? "Resolved" : "Open"}
|
{report.action_taken ? "Resolved" : "Open"}
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { ErrorBoundary } from "../../lib/navigation/error";
|
||||||
import ImportExport from "./domain-permissions/import-export";
|
import ImportExport from "./domain-permissions/import-export";
|
||||||
import DomainPermissionsOverview from "./domain-permissions/overview";
|
import DomainPermissionsOverview from "./domain-permissions/overview";
|
||||||
import DomainPermDetail from "./domain-permissions/detail";
|
import DomainPermDetail from "./domain-permissions/detail";
|
||||||
import AccountsOverview from "./accounts";
|
import AccountsSearch from "./accounts";
|
||||||
import AccountsPending from "./accounts/pending";
|
import AccountsPending from "./accounts/pending";
|
||||||
import AccountDetail from "./accounts/detail";
|
import AccountDetail from "./accounts/detail";
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ import AccountDetail from "./accounts/detail";
|
||||||
/**
|
/**
|
||||||
* - /settings/moderation/reports/overview
|
* - /settings/moderation/reports/overview
|
||||||
* - /settings/moderation/reports/:reportId
|
* - /settings/moderation/reports/:reportId
|
||||||
* - /settings/moderation/accounts/overview
|
* - /settings/moderation/accounts/search
|
||||||
* - /settings/moderation/accounts/pending
|
* - /settings/moderation/accounts/pending
|
||||||
* - /settings/moderation/accounts/:accountID
|
* - /settings/moderation/accounts/:accountID
|
||||||
* - /settings/moderation/domain-permissions/:permType
|
* - /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/pending
|
||||||
* - /settings/moderation/accounts/:accountID
|
* - /settings/moderation/accounts/:accountID
|
||||||
*/
|
*/
|
||||||
|
@ -109,10 +109,10 @@ function ModerationAccountsRouter() {
|
||||||
<Router base={thisBase}>
|
<Router base={thisBase}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/overview" component={AccountsOverview}/>
|
<Route path="/search" component={AccountsSearch}/>
|
||||||
<Route path="/pending" component={AccountsPending}/>
|
<Route path="/pending" component={AccountsPending}/>
|
||||||
<Route path="/:accountID" component={AccountDetail}/>
|
<Route path="/:accountID" component={AccountDetail}/>
|
||||||
<Route><Redirect to="/overview"/></Route>
|
<Route><Redirect to="/search"/></Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
@ -1468,6 +1468,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@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@*":
|
"@types/prop-types@*":
|
||||||
version "15.7.8"
|
version "15.7.8"
|
||||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3"
|
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:
|
dependencies:
|
||||||
error-ex "^1.2.0"
|
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:
|
parse-ms@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
|
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
|
||||||
|
|
Loading…
Reference in New Issue