mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-30 12:21:09 +00:00
[feature] Admin accounts endpoints; approve/reject sign-ups (#2826)
* update settings panels, add pending overview + approve/deny functions * add admin accounts get, approve, reject * send approved/rejected emails * use signup URL * docs! * email * swagger * web linting * fix email tests * wee lil fixerinos * use new paging logic for GetAccounts() series of admin endpoints, small changes to query building * shuffle useAccountIDIn check *before* adding to query * fix parse from toot react error * use `netip.Addr` * put valid slices in globals * optimistic updates for account state --------- Co-authored-by: kim <grufwub@gmail.com>
This commit is contained in:
parent
1439042104
commit
89e0cfd874
74 changed files with 4102 additions and 545 deletions
59
docs/admin/signups.md
Normal file
59
docs/admin/signups.md
Normal file
|
@ -0,0 +1,59 @@
|
|||
# New Account Sign-Ups
|
||||
|
||||
If you want to allow more people than just you to have an account on your instance, you can open your instance to new account sign-ups / registrations.
|
||||
|
||||
Be wary that as instance admin, like it or not, you are responsible for what people post on your instance. If users on your instance harass or annoy other people on the fediverse, you may find your instance gets a bad reputation and becomes blocked by others. Moderating a space properly takes work. As such, you should carefully consider whether or not you are willing and able to do moderation, and consider accepting sign-ups on your instance only from friends and people that you really trust.
|
||||
|
||||
!!! warning
|
||||
For the sign-up flow to work as intended, your instance [should be configured to send emails](../configuration/smtp.md).
|
||||
|
||||
As mentioned below, several emails are sent during the sign-up flow, both to you (as admin/moderator) and to the applicant, including an email asking them to confirm their email address.
|
||||
|
||||
If they cannot receive this email (because your instance is not configured to send emails), you will have to manually confirm the account by [using the CLI tool](../admin/cli.md#gotosocial-admin-account-confirm).
|
||||
|
||||
## Opening Sign-Ups
|
||||
|
||||
You can open new account sign-ups for your instance by changing the variable `accounts-registration-open` to `true` in your [configuration](../configuration/accounts.md), and restarting your GoToSocial instance.
|
||||
|
||||
A sign-up form for your instance will be available at the `/signup` endpoint. For example, `https://your-instance.example.org/signup`.
|
||||
|
||||
![Sign-up form, showing email, password, username, and reason fields.](../assets/signup-form.png)
|
||||
|
||||
Also, your instance homepage and "about" pages will be updated to reflect that registrations are open.
|
||||
|
||||
When someone submits a new sign-up, they'll receive an email at the provided email address, giving them a link to confirm that the address really belongs to them.
|
||||
|
||||
In the meantime, admins and moderators on your instance will receive an email and a notification that a new sign-up has been submitted.
|
||||
|
||||
## Handling Sign-Ups
|
||||
|
||||
Instance admins and moderators can handle a new sign-up by either approving or rejecting it via the "accounts" -> "pending" section in the admin panel.
|
||||
|
||||
![Admin settings panel open to "accounts" -> "pending", showing one account in a list.](../assets/signup-pending.png)
|
||||
|
||||
If you have no sign-ups, the list pictured above will be empty. If you have a pending account sign-up, however, you can click on it to open that account in the account details screen:
|
||||
|
||||
![Details of a new pending account, giving options to approve or reject the sign-up.](../assets/signup-account.png)
|
||||
|
||||
At the bottom, you will find actions that let you approve or reject the sign-up.
|
||||
|
||||
If you **approve** the sign-up, the account will be marked as "approved", and an email will be sent to the applicant informing them their sign-up has been approved, and reminding them to confirm their email address if they haven't already done so. If they have already confirmed their email address, they will be able to log in and start using their account.
|
||||
|
||||
If you **reject** the sign-up, you may wish to inform the applicant that their sign-up has been rejected, which you can do by ticking the "send email" checkbox. This will send a short email to the applicant informing them of the rejection. If you wish, you can add a custom message, which will be added at the bottom of the email. You can also add a private note that will be visible to other admins only.
|
||||
|
||||
!!! warning
|
||||
You may want to hold off on approving a sign-up until they have confirmed their email address, in case the applicant made a typo when submitting, or the email address they provided does not actually belong to them. If they cannot confirm their email address, they will not be able to log in and use their account.
|
||||
|
||||
## Sign-Up Limits
|
||||
|
||||
To avoid sign-up backlogs overwhelming admins and moderators, GoToSocial limits the sign-up pending backlog to 20 accounts. Once there are 20 accounts pending in the backlog waiting to be handled by an admin or moderator, new sign-ups will not be accepted via the form.
|
||||
|
||||
New sign-ups will also not be accepted via the form if 10 or more new account sign-ups have been approved in the last 24 hours, to avoid instances rapidly expanding beyond the capabilities of moderators.
|
||||
|
||||
In both cases, applicants will be shown an error message explaining why they could not submit the form, and inviting them to try again later.
|
||||
|
||||
To combat spam accounts, GoToSocial account sign-ups **always** require manual approval by an administrator, and applicants must **always** confirm their email address before they are able to log in and post.
|
||||
|
||||
## Sign-Up Via Invite
|
||||
|
||||
NOT IMPLEMENTED YET: in a future update, admins and moderators will be able to create and send invites that allow accounts to be created even when public sign-up is closed, and to pre-approve accounts created via invitation, and/or allow them to override the sign-up limits described above.
|
|
@ -3680,6 +3680,166 @@ paths:
|
|||
summary: Verify a token by returning account details pertaining to it.
|
||||
tags:
|
||||
- accounts
|
||||
/api/v1/admin/accounts:
|
||||
get:
|
||||
description: |-
|
||||
The next and previous queries can be parsed from the returned Link header.
|
||||
Example:
|
||||
|
||||
```
|
||||
<https://example.org/api/v1/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
|
||||
````
|
||||
operationId: adminAccountsGetV1
|
||||
parameters:
|
||||
- default: false
|
||||
description: Filter for local accounts.
|
||||
in: query
|
||||
name: local
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for remote accounts.
|
||||
in: query
|
||||
name: remote
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for currently active accounts.
|
||||
in: query
|
||||
name: active
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for currently pending accounts.
|
||||
in: query
|
||||
name: pending
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for currently disabled accounts.
|
||||
in: query
|
||||
name: disabled
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for currently silenced accounts.
|
||||
in: query
|
||||
name: silenced
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for currently suspended accounts.
|
||||
in: query
|
||||
name: suspended
|
||||
type: boolean
|
||||
- default: false
|
||||
description: Filter for accounts force-marked as sensitive.
|
||||
in: query
|
||||
name: sensitized
|
||||
type: boolean
|
||||
- description: Search for the given username.
|
||||
in: query
|
||||
name: username
|
||||
type: string
|
||||
- description: Search for the given display name.
|
||||
in: query
|
||||
name: display_name
|
||||
type: string
|
||||
- description: Filter by the given domain.
|
||||
in: query
|
||||
name: by_domain
|
||||
type: string
|
||||
- description: Lookup a user with this email.
|
||||
in: query
|
||||
name: email
|
||||
type: string
|
||||
- description: Lookup users with this IP address.
|
||||
in: query
|
||||
name: ip
|
||||
type: string
|
||||
- default: false
|
||||
description: Filter for staff accounts.
|
||||
in: query
|
||||
name: staff
|
||||
type: boolean
|
||||
- description: All results returned will be older than the item with this ID.
|
||||
in: query
|
||||
name: max_id
|
||||
type: string
|
||||
- description: All results returned will be newer than the item with this ID.
|
||||
in: query
|
||||
name: since_id
|
||||
type: string
|
||||
- description: Returns results immediately newer than the item with this ID.
|
||||
in: query
|
||||
name: min_id
|
||||
type: string
|
||||
- default: 100
|
||||
description: Maximum number of results to return.
|
||||
in: query
|
||||
maximum: 200
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
headers:
|
||||
Link:
|
||||
description: Links to the next and previous queries.
|
||||
type: string
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/adminAccountInfo'
|
||||
type: array
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- admin
|
||||
summary: View + page through known accounts according to given filters.
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/accounts/{id}:
|
||||
get:
|
||||
operationId: adminAccountGet
|
||||
parameters:
|
||||
- description: ID of the account.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/adminAccountInfo'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- admin
|
||||
summary: View one account.
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/accounts/{id}/action:
|
||||
post:
|
||||
consumes:
|
||||
|
@ -3725,6 +3885,86 @@ paths:
|
|||
summary: Perform an admin action on an account.
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/accounts/{id}/approve:
|
||||
post:
|
||||
operationId: adminAccountApprove
|
||||
parameters:
|
||||
- description: ID of the account.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The now-approved account.
|
||||
schema:
|
||||
$ref: '#/definitions/adminAccountInfo'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- admin
|
||||
summary: Approve pending account.
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/accounts/{id}/reject:
|
||||
post:
|
||||
operationId: adminAccountReject
|
||||
parameters:
|
||||
- description: ID of the account.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Comment to leave on why the account was denied. The comment will be visible to admins only.
|
||||
in: formData
|
||||
name: private_comment
|
||||
type: string
|
||||
- description: Message to include in email to applicant. Will be included only if send_email is true.
|
||||
in: formData
|
||||
name: message
|
||||
type: string
|
||||
- description: Send an email to the applicant informing them that their sign-up has been rejected.
|
||||
in: formData
|
||||
name: send_email
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The now-rejected account.
|
||||
schema:
|
||||
$ref: '#/definitions/adminAccountInfo'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- admin
|
||||
summary: Reject pending account.
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/custom_emojis:
|
||||
get:
|
||||
description: |-
|
||||
|
@ -7934,6 +8174,109 @@ paths:
|
|||
summary: Change the password of authenticated user.
|
||||
tags:
|
||||
- user
|
||||
/api/v2/admin/accounts:
|
||||
get:
|
||||
description: |-
|
||||
The next and previous queries can be parsed from the returned Link header.
|
||||
Example:
|
||||
|
||||
```
|
||||
<https://example.org/api/v2/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
|
||||
````
|
||||
operationId: adminAccountsGetV2
|
||||
parameters:
|
||||
- description: Filter for `local` or `remote` accounts.
|
||||
in: query
|
||||
name: origin
|
||||
type: string
|
||||
- description: Filter for `active`, `pending`, `disabled`, `silenced`, or `suspended` accounts.
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
- description: Filter for accounts with staff permissions (users that can manage reports).
|
||||
in: query
|
||||
name: permissions
|
||||
type: string
|
||||
- description: Filter for users with these roles.
|
||||
in: query
|
||||
items:
|
||||
type: string
|
||||
name: role_ids[]
|
||||
type: array
|
||||
- description: Lookup users invited by the account with this ID.
|
||||
in: query
|
||||
name: invited_by
|
||||
type: string
|
||||
- description: Search for the given username.
|
||||
in: query
|
||||
name: username
|
||||
type: string
|
||||
- description: Search for the given display name.
|
||||
in: query
|
||||
name: display_name
|
||||
type: string
|
||||
- description: Filter by the given domain.
|
||||
in: query
|
||||
name: by_domain
|
||||
type: string
|
||||
- description: Lookup a user with this email.
|
||||
in: query
|
||||
name: email
|
||||
type: string
|
||||
- description: Lookup users with this IP address.
|
||||
in: query
|
||||
name: ip
|
||||
type: string
|
||||
- description: All results returned will be older than the item with this ID.
|
||||
in: query
|
||||
name: max_id
|
||||
type: string
|
||||
- description: All results returned will be newer than the item with this ID.
|
||||
in: query
|
||||
name: since_id
|
||||
type: string
|
||||
- description: Returns results immediately newer than the item with this ID.
|
||||
in: query
|
||||
name: min_id
|
||||
type: string
|
||||
- default: 100
|
||||
description: Maximum number of results to return.
|
||||
in: query
|
||||
maximum: 200
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
headers:
|
||||
Link:
|
||||
description: Links to the next and previous queries.
|
||||
type: string
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/adminAccountInfo'
|
||||
type: array
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- admin
|
||||
summary: View + page through known accounts according to given filters.
|
||||
tags:
|
||||
- admin
|
||||
/api/v2/instance:
|
||||
get:
|
||||
operationId: instanceGetV2
|
||||
|
|
BIN
docs/assets/signup-account.png
Normal file
BIN
docs/assets/signup-account.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 76 KiB |
BIN
docs/assets/signup-form.png
Normal file
BIN
docs/assets/signup-form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
BIN
docs/assets/signup-pending.png
Normal file
BIN
docs/assets/signup-pending.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 72 KiB |
105
internal/api/client/admin/accountapprove.go
Normal file
105
internal/api/client/admin/accountapprove.go
Normal file
|
@ -0,0 +1,105 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AccountApprovePOSTHandler swagger:operation POST /api/v1/admin/accounts/{id}/approve adminAccountApprove
|
||||
//
|
||||
// Approve pending account.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - admin
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// required: true
|
||||
// in: path
|
||||
// description: ID of the account.
|
||||
// type: string
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - admin
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: The now-approved account.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/adminAccountInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AccountApprovePOSTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !*authed.User.Admin {
|
||||
err := fmt.Errorf("user %s not an admin", authed.User.ID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
account, errWithCode := m.processor.Admin().AccountApprove(
|
||||
c.Request.Context(),
|
||||
authed.Account,
|
||||
targetAcctID,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, account)
|
||||
}
|
101
internal/api/client/admin/accountget.go
Normal file
101
internal/api/client/admin/accountget.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AccountGETHandler swagger:operation GET /api/v1/admin/accounts/{id} adminAccountGet
|
||||
//
|
||||
// View one account.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - admin
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// required: true
|
||||
// in: path
|
||||
// description: ID of the account.
|
||||
// type: string
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - admin
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: OK
|
||||
// schema:
|
||||
// "$ref": "#/definitions/adminAccountInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AccountGETHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !*authed.User.Admin {
|
||||
err := fmt.Errorf("user %s not an admin", authed.User.ID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
account, errWithCode := m.processor.Admin().AccountGet(c.Request.Context(), targetAcctID)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, account)
|
||||
}
|
136
internal/api/client/admin/accountreject.go
Normal file
136
internal/api/client/admin/accountreject.go
Normal file
|
@ -0,0 +1,136 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AccountRejectPOSTHandler swagger:operation POST /api/v1/admin/accounts/{id}/reject adminAccountReject
|
||||
//
|
||||
// Reject pending account.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - admin
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// required: true
|
||||
// in: path
|
||||
// description: ID of the account.
|
||||
// type: string
|
||||
// -
|
||||
// name: private_comment
|
||||
// in: formData
|
||||
// description: >-
|
||||
// Comment to leave on why the account was denied.
|
||||
// The comment will be visible to admins only.
|
||||
// type: string
|
||||
// -
|
||||
// name: message
|
||||
// in: formData
|
||||
// description: >-
|
||||
// Message to include in email to applicant.
|
||||
// Will be included only if send_email is true.
|
||||
// type: string
|
||||
// -
|
||||
// name: send_email
|
||||
// in: formData
|
||||
// description: >-
|
||||
// Send an email to the applicant informing
|
||||
// them that their sign-up has been rejected.
|
||||
// type: boolean
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - admin
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: The now-rejected account.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/adminAccountInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AccountRejectPOSTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !*authed.User.Admin {
|
||||
err := fmt.Errorf("user %s not an admin", authed.User.ID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
form := new(apimodel.AdminAccountRejectRequest)
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
account, errWithCode := m.processor.Admin().AccountReject(
|
||||
c.Request.Context(),
|
||||
authed.Account,
|
||||
targetAcctID,
|
||||
form.PrivateComment,
|
||||
form.SendEmail,
|
||||
form.Message,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, account)
|
||||
}
|
348
internal/api/client/admin/accountsgetv1.go
Normal file
348
internal/api/client/admin/accountsgetv1.go
Normal file
|
@ -0,0 +1,348 @@
|
|||
// 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/>.
|
||||
|
||||
// AccountsGETHandlerV1 swagger:operation GET /api/v1/admin/accounts adminAccountsGetV1
|
||||
//
|
||||
// View + page through known accounts according to given filters.
|
||||
//
|
||||
// The next and previous queries can be parsed from the returned Link header.
|
||||
// Example:
|
||||
//
|
||||
// ```
|
||||
// <https://example.org/api/v1/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
|
||||
// ````
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - admin
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: local
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for local accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: remote
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for remote accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: active
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for currently active accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: pending
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for currently pending accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: disabled
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for currently disabled accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: silenced
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for currently silenced accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: suspended
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for currently suspended accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: sensitized
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for accounts force-marked as sensitive.
|
||||
// default: false
|
||||
// -
|
||||
// name: username
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Search for the given username.
|
||||
// -
|
||||
// name: display_name
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Search for the given display name.
|
||||
// -
|
||||
// name: by_domain
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Filter by the given domain.
|
||||
// -
|
||||
// name: email
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Lookup a user with this email.
|
||||
// -
|
||||
// name: ip
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Lookup users with this IP address.
|
||||
// -
|
||||
// name: staff
|
||||
// in: query
|
||||
// type: boolean
|
||||
// description: Filter for staff accounts.
|
||||
// default: false
|
||||
// -
|
||||
// name: max_id
|
||||
// in: query
|
||||
// type: string
|
||||
// description: All results returned will be older than the item with this ID.
|
||||
// -
|
||||
// name: since_id
|
||||
// in: query
|
||||
// type: string
|
||||
// description: All results returned will be newer than the item with this ID.
|
||||
// -
|
||||
// name: min_id
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Returns results immediately newer than the item with this ID.
|
||||
// -
|
||||
// name: limit
|
||||
// in: query
|
||||
// type: integer
|
||||
// description: Maximum number of results to return.
|
||||
// default: 100
|
||||
// maximum: 200
|
||||
// minimum: 1
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - admin
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// headers:
|
||||
// Link:
|
||||
// type: string
|
||||
// description: Links to the next and previous queries.
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/adminAccountInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
func (m *Module) AccountsGETV1Handler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !*authed.User.Admin {
|
||||
err := fmt.Errorf("user %s not an admin", authed.User.ID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
page, errWithCode := paging.ParseIDPage(c, 1, 200, 100)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
/* Translate to v2 `origin` query param */
|
||||
|
||||
local, errWithCode := apiutil.ParseLocal(c.Query(apiutil.LocalKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
remote, errWithCode := apiutil.ParseAdminRemote(c.Query(apiutil.AdminRemoteKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if local && remote {
|
||||
keys := []string{apiutil.LocalKey, apiutil.AdminRemoteKey}
|
||||
err := fmt.Errorf("only one of %+v can be true", keys)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
var origin string
|
||||
if local {
|
||||
origin = "local"
|
||||
} else if remote {
|
||||
origin = "remote"
|
||||
}
|
||||
|
||||
/* Translate to v2 `status` query param */
|
||||
|
||||
active, errWithCode := apiutil.ParseAdminActive(c.Query(apiutil.AdminActiveKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
pending, errWithCode := apiutil.ParseAdminPending(c.Query(apiutil.AdminPendingKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
disabled, errWithCode := apiutil.ParseAdminDisabled(c.Query(apiutil.AdminDisabledKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
silenced, errWithCode := apiutil.ParseAdminSilenced(c.Query(apiutil.AdminSilencedKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
suspended, errWithCode := apiutil.ParseAdminSuspended(c.Query(apiutil.AdminSuspendedKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure only one `status` query param set.
|
||||
var status string
|
||||
states := map[string]bool{
|
||||
apiutil.AdminActiveKey: active,
|
||||
apiutil.AdminPendingKey: pending,
|
||||
apiutil.AdminDisabledKey: disabled,
|
||||
apiutil.AdminSilencedKey: silenced,
|
||||
apiutil.AdminSuspendedKey: suspended,
|
||||
}
|
||||
for k, v := range states {
|
||||
if !v {
|
||||
// False status,
|
||||
// so irrelevant.
|
||||
continue
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
// Status was already set by another
|
||||
// query param, this is an error.
|
||||
keys := []string{
|
||||
apiutil.AdminActiveKey,
|
||||
apiutil.AdminPendingKey,
|
||||
apiutil.AdminDisabledKey,
|
||||
apiutil.AdminSilencedKey,
|
||||
apiutil.AdminSuspendedKey,
|
||||
}
|
||||
err := fmt.Errorf("only one of %+v can be true", keys)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Use this
|
||||
// account status.
|
||||
status = k
|
||||
}
|
||||
|
||||
/* Translate to v2 `permissions` query param */
|
||||
|
||||
staff, errWithCode := apiutil.ParseAdminStaff(c.Query(apiutil.AdminStaffKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
var permissions string
|
||||
if staff {
|
||||
permissions = "staff"
|
||||
}
|
||||
|
||||
// Parse out all optional params from the query.
|
||||
params := &apimodel.AdminGetAccountsRequest{
|
||||
Origin: origin,
|
||||
Status: status,
|
||||
Permissions: permissions,
|
||||
RoleIDs: nil, // Can't do in V1.
|
||||
InvitedBy: "", // Can't do in V1.
|
||||
Username: c.Query(apiutil.UsernameKey),
|
||||
DisplayName: c.Query(apiutil.AdminDisplayNameKey),
|
||||
ByDomain: c.Query(apiutil.AdminByDomainKey),
|
||||
Email: c.Query(apiutil.AdminEmailKey),
|
||||
IP: c.Query(apiutil.AdminIPKey),
|
||||
APIVersion: 1,
|
||||
}
|
||||
|
||||
resp, errWithCode := m.processor.Admin().AccountsGet(
|
||||
c.Request.Context(),
|
||||
params,
|
||||
page,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.LinkHeader != "" {
|
||||
c.Header("Link", resp.LinkHeader)
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, resp.Items)
|
||||
}
|
212
internal/api/client/admin/accountsgetv2.go
Normal file
212
internal/api/client/admin/accountsgetv2.go
Normal file
|
@ -0,0 +1,212 @@
|
|||
// 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/>.
|
||||
|
||||
// AccountsGETHandlerV2 swagger:operation GET /api/v2/admin/accounts adminAccountsGetV2
|
||||
//
|
||||
// View + page through known accounts according to given filters.
|
||||
//
|
||||
// The next and previous queries can be parsed from the returned Link header.
|
||||
// Example:
|
||||
//
|
||||
// ```
|
||||
// <https://example.org/api/v2/admin/accounts?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v2/admin/accounts?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
|
||||
// ````
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - admin
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: origin
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Filter for `local` or `remote` accounts.
|
||||
// -
|
||||
// name: status
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Filter for `active`, `pending`, `disabled`, `silenced`, or `suspended` accounts.
|
||||
// -
|
||||
// name: permissions
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Filter for accounts with staff permissions (users that can manage reports).
|
||||
// -
|
||||
// name: role_ids[]
|
||||
// in: query
|
||||
// type: array
|
||||
// items:
|
||||
// type: string
|
||||
// description: Filter for users with these roles.
|
||||
// -
|
||||
// name: invited_by
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Lookup users invited by the account with this ID.
|
||||
// -
|
||||
// name: username
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Search for the given username.
|
||||
// -
|
||||
// name: display_name
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Search for the given display name.
|
||||
// -
|
||||
// name: by_domain
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Filter by the given domain.
|
||||
// -
|
||||
// name: email
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Lookup a user with this email.
|
||||
// -
|
||||
// name: ip
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Lookup users with this IP address.
|
||||
// -
|
||||
// name: max_id
|
||||
// in: query
|
||||
// type: string
|
||||
// description: All results returned will be older than the item with this ID.
|
||||
// -
|
||||
// name: since_id
|
||||
// in: query
|
||||
// type: string
|
||||
// description: All results returned will be newer than the item with this ID.
|
||||
// -
|
||||
// name: min_id
|
||||
// in: query
|
||||
// type: string
|
||||
// description: Returns results immediately newer than the item with this ID.
|
||||
// -
|
||||
// name: limit
|
||||
// in: query
|
||||
// type: integer
|
||||
// description: Maximum number of results to return.
|
||||
// default: 100
|
||||
// maximum: 200
|
||||
// minimum: 1
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - admin
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// headers:
|
||||
// Link:
|
||||
// type: string
|
||||
// description: Links to the next and previous queries.
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/adminAccountInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
func (m *Module) AccountsGETV2Handler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !*authed.User.Admin {
|
||||
err := fmt.Errorf("user %s not an admin", authed.User.ID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
page, errWithCode := paging.ParseIDPage(c, 1, 200, 100)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse out all optional params from the query.
|
||||
params := &apimodel.AdminGetAccountsRequest{
|
||||
Origin: c.Query(apiutil.AdminOriginKey),
|
||||
Status: c.Query(apiutil.AdminStatusKey),
|
||||
Permissions: c.Query(apiutil.AdminPermissionsKey),
|
||||
RoleIDs: c.QueryArray(apiutil.AdminRoleIDsKey),
|
||||
InvitedBy: c.Query(apiutil.AdminInvitedByKey),
|
||||
Username: c.Query(apiutil.UsernameKey),
|
||||
DisplayName: c.Query(apiutil.AdminDisplayNameKey),
|
||||
ByDomain: c.Query(apiutil.AdminByDomainKey),
|
||||
Email: c.Query(apiutil.AdminEmailKey),
|
||||
IP: c.Query(apiutil.AdminIPKey),
|
||||
APIVersion: 2,
|
||||
}
|
||||
|
||||
resp, errWithCode := m.processor.Admin().AccountsGet(
|
||||
c.Request.Context(),
|
||||
params,
|
||||
page,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.LinkHeader != "" {
|
||||
c.Header("Link", resp.LinkHeader)
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, resp.Items)
|
||||
}
|
|
@ -39,9 +39,12 @@ const (
|
|||
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + IDKey
|
||||
HeaderBlocksPath = BasePath + "/header_blocks"
|
||||
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + IDKey
|
||||
AccountsPath = BasePath + "/accounts"
|
||||
AccountsPathWithID = AccountsPath + "/:" + IDKey
|
||||
AccountsV1Path = BasePath + "/accounts"
|
||||
AccountsV2Path = "/v2/admin/accounts"
|
||||
AccountsPathWithID = AccountsV1Path + "/:" + IDKey
|
||||
AccountsActionPath = AccountsPathWithID + "/action"
|
||||
AccountsApprovePath = AccountsPathWithID + "/approve"
|
||||
AccountsRejectPath = AccountsPathWithID + "/reject"
|
||||
MediaCleanupPath = BasePath + "/media_cleanup"
|
||||
MediaRefetchPath = BasePath + "/media_refetch"
|
||||
ReportsPath = BasePath + "/reports"
|
||||
|
@ -113,7 +116,12 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
|||
attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler)
|
||||
|
||||
// accounts stuff
|
||||
attachHandler(http.MethodGet, AccountsV1Path, m.AccountsGETV1Handler)
|
||||
attachHandler(http.MethodGet, AccountsV2Path, m.AccountsGETV2Handler)
|
||||
attachHandler(http.MethodGet, AccountsPathWithID, m.AccountGETHandler)
|
||||
attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler)
|
||||
attachHandler(http.MethodPost, AccountsApprovePath, m.AccountApprovePOSTHandler)
|
||||
attachHandler(http.MethodPost, AccountsRejectPath, m.AccountRejectPOSTHandler)
|
||||
|
||||
// media stuff
|
||||
attachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler)
|
||||
|
|
|
@ -229,3 +229,52 @@ type DebugAPUrlResponse struct {
|
|||
// may be an error, may be both!
|
||||
ResponseBody string `json:"response_body"`
|
||||
}
|
||||
|
||||
// AdminGetAccountsRequest models a request
|
||||
// to get an admin view of one or more
|
||||
// accounts using given parameters.
|
||||
//
|
||||
// swagger:ignore
|
||||
type AdminGetAccountsRequest struct {
|
||||
// Filter for `local` or `remote` accounts.
|
||||
Origin string
|
||||
// Filter for `active`, `pending`, `disabled`,
|
||||
// `silenced`, or `suspended` accounts.
|
||||
Status string
|
||||
// Filter for accounts with staff perms
|
||||
// (users that can manage reports).
|
||||
Permissions string
|
||||
// Filter for users with these roles.
|
||||
RoleIDs []string
|
||||
// Lookup users invited by the account with this ID.
|
||||
InvitedBy string
|
||||
// Search for the given username.
|
||||
Username string
|
||||
// Search for the given display name.
|
||||
DisplayName string
|
||||
// Filter by the given domain.
|
||||
ByDomain string
|
||||
// Lookup a user with this email.
|
||||
Email string
|
||||
// Lookup users with this IP address.
|
||||
IP string
|
||||
// API version to use for this request (1 or 2).
|
||||
// Set internally, not by callers.
|
||||
APIVersion int
|
||||
}
|
||||
|
||||
// AdminAccountRejectRequest models a
|
||||
// request to deny a new account sign-up.
|
||||
//
|
||||
// swagger:ignore
|
||||
type AdminAccountRejectRequest struct {
|
||||
// Comment to leave on why the account was denied.
|
||||
// The comment will be visible to admins only.
|
||||
PrivateComment string `form:"private_comment" json:"private_comment"`
|
||||
// Message to include in email to applicant.
|
||||
// Will be included only if send_email is true.
|
||||
Message string `form:"message" json:"message"`
|
||||
// Send an email to the applicant informing
|
||||
// them that their sign-up has been rejected.
|
||||
SendEmail bool `form:"send_email" json:"send_email"`
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ const (
|
|||
MaxIDKey = "max_id"
|
||||
SinceIDKey = "since_id"
|
||||
MinIDKey = "min_id"
|
||||
UsernameKey = "username"
|
||||
|
||||
/* AP endpoint keys */
|
||||
|
||||
|
@ -61,19 +62,62 @@ const (
|
|||
|
||||
/* Web endpoint keys */
|
||||
|
||||
WebUsernameKey = "username"
|
||||
WebStatusIDKey = "status"
|
||||
|
||||
/* Domain permission keys */
|
||||
|
||||
DomainPermissionExportKey = "export"
|
||||
DomainPermissionImportKey = "import"
|
||||
|
||||
/* Admin query keys */
|
||||
|
||||
AdminRemoteKey = "remote"
|
||||
AdminActiveKey = "active"
|
||||
AdminPendingKey = "pending"
|
||||
AdminDisabledKey = "disabled"
|
||||
AdminSilencedKey = "silenced"
|
||||
AdminSuspendedKey = "suspended"
|
||||
AdminSensitizedKey = "sensitized"
|
||||
AdminDisplayNameKey = "display_name"
|
||||
AdminByDomainKey = "by_domain"
|
||||
AdminEmailKey = "email"
|
||||
AdminIPKey = "ip"
|
||||
AdminStaffKey = "staff"
|
||||
AdminOriginKey = "origin"
|
||||
AdminStatusKey = "status"
|
||||
AdminPermissionsKey = "permissions"
|
||||
AdminRoleIDsKey = "role_ids[]"
|
||||
AdminInvitedByKey = "invited_by"
|
||||
)
|
||||
|
||||
/*
|
||||
Parse functions for *OPTIONAL* parameters with default values.
|
||||
*/
|
||||
|
||||
func ParseMaxID(value string, defaultValue string) string {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func ParseSinceID(value string, defaultValue string) string {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func ParseMinID(value string, defaultValue string) string {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func ParseLimit(value string, defaultValue int, max, min int) (int, gtserror.WithCode) {
|
||||
i, err := parseInt(value, defaultValue, max, min, LimitKey)
|
||||
if err != nil {
|
||||
|
@ -87,14 +131,6 @@ func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
|||
return parseBool(value, defaultValue, LocalKey)
|
||||
}
|
||||
|
||||
func ParseMaxID(value string, defaultValue string) string {
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
|
||||
}
|
||||
|
@ -123,6 +159,34 @@ func ParseOnlyOtherAccounts(value string, defaultValue bool) (bool, gtserror.Wit
|
|||
return parseBool(value, defaultValue, OnlyOtherAccountsKey)
|
||||
}
|
||||
|
||||
func ParseAdminRemote(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminRemoteKey)
|
||||
}
|
||||
|
||||
func ParseAdminActive(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminActiveKey)
|
||||
}
|
||||
|
||||
func ParseAdminPending(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminPendingKey)
|
||||
}
|
||||
|
||||
func ParseAdminDisabled(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminDisabledKey)
|
||||
}
|
||||
|
||||
func ParseAdminSilenced(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminSilencedKey)
|
||||
}
|
||||
|
||||
func ParseAdminSuspended(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminSuspendedKey)
|
||||
}
|
||||
|
||||
func ParseAdminStaff(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||
return parseBool(value, defaultValue, AdminStaffKey)
|
||||
}
|
||||
|
||||
/*
|
||||
Parse functions for *REQUIRED* parameters.
|
||||
*/
|
||||
|
@ -187,8 +251,8 @@ func ParseTagName(value string) (string, gtserror.WithCode) {
|
|||
return value, nil
|
||||
}
|
||||
|
||||
func ParseWebUsername(value string) (string, gtserror.WithCode) {
|
||||
key := WebUsernameKey
|
||||
func ParseUsername(value string) (string, gtserror.WithCode) {
|
||||
key := UsernameKey
|
||||
|
||||
if value == "" {
|
||||
return "", requiredError(key)
|
||||
|
|
|
@ -19,9 +19,11 @@ package db
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// Account contains functions related to account getting/setting/creation.
|
||||
|
@ -56,6 +58,25 @@ type Account interface {
|
|||
// GetAccountByFollowersURI returns one account with the given followers_uri, or an error if something goes wrong.
|
||||
GetAccountByFollowersURI(ctx context.Context, uri string) (*gtsmodel.Account, error)
|
||||
|
||||
// GetAccounts returns accounts
|
||||
// with the given parameters.
|
||||
GetAccounts(
|
||||
ctx context.Context,
|
||||
origin string,
|
||||
status string,
|
||||
mods bool,
|
||||
invitedBy string,
|
||||
username string,
|
||||
displayName string,
|
||||
domain string,
|
||||
email string,
|
||||
ip netip.Addr,
|
||||
page *paging.Page,
|
||||
) (
|
||||
[]*gtsmodel.Account,
|
||||
error,
|
||||
)
|
||||
|
||||
// PopulateAccount ensures that all sub-models of an account are populated (e.g. avatar, header etc).
|
||||
PopulateAccount(ctx context.Context, account *gtsmodel.Account) error
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ package bundb
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -31,6 +33,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
|
@ -249,6 +252,257 @@ func (a *accountDB) GetInstanceAccount(ctx context.Context, domain string) (*gts
|
|||
return a.GetAccountByUsernameDomain(ctx, username, domain)
|
||||
}
|
||||
|
||||
func (a *accountDB) GetAccounts(
|
||||
ctx context.Context,
|
||||
origin string,
|
||||
status string,
|
||||
mods bool,
|
||||
invitedBy string,
|
||||
username string,
|
||||
displayName string,
|
||||
domain string,
|
||||
email string,
|
||||
ip netip.Addr,
|
||||
page *paging.Page,
|
||||
) (
|
||||
[]*gtsmodel.Account,
|
||||
error,
|
||||
) {
|
||||
var (
|
||||
// local users lists,
|
||||
// required for some
|
||||
// limiting parameters.
|
||||
users []*gtsmodel.User
|
||||
|
||||
// lazyLoadUsers only loads the users
|
||||
// slice if it's required by params.
|
||||
lazyLoadUsers = func() (err error) {
|
||||
if users == nil {
|
||||
users, err = a.state.DB.GetAllUsers(gtscontext.SetBarebones(ctx))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting users: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get paging params.
|
||||
//
|
||||
// Note this may be min_id OR since_id
|
||||
// from the API, this gets handled below
|
||||
// when checking order to reverse slice.
|
||||
minID = page.GetMin()
|
||||
maxID = page.GetMax()
|
||||
limit = page.GetLimit()
|
||||
order = page.GetOrder()
|
||||
|
||||
// Make educated guess for slice size
|
||||
accountIDs = make([]string, 0, limit)
|
||||
accountIDIn []string
|
||||
|
||||
useAccountIDIn bool
|
||||
)
|
||||
|
||||
q := a.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
|
||||
// Select only IDs from table
|
||||
Column("account.id")
|
||||
|
||||
// Return only accounts OLDER
|
||||
// than account with maxID.
|
||||
if maxID != "" {
|
||||
maxIDAcct, err := a.GetAccountByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
maxID,
|
||||
)
|
||||
if err != nil {
|
||||
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 NEWER
|
||||
// than account with minID.
|
||||
if minID != "" {
|
||||
minIDAcct, err := a.GetAccountByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
minID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting minID account %s: %w", minID, err)
|
||||
}
|
||||
|
||||
q = q.Where("? > ?", bun.Ident("account.created_at"), minIDAcct.CreatedAt)
|
||||
}
|
||||
|
||||
switch status {
|
||||
|
||||
case "active":
|
||||
// Get only enabled accounts.
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if !*user.Disabled {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
}
|
||||
useAccountIDIn = true
|
||||
|
||||
case "pending":
|
||||
// Get only unapproved accounts.
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if !*user.Approved {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
}
|
||||
useAccountIDIn = true
|
||||
|
||||
case "disabled":
|
||||
// Get only disabled accounts.
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if *user.Disabled {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
}
|
||||
useAccountIDIn = true
|
||||
|
||||
case "silenced":
|
||||
// Get only silenced accounts.
|
||||
q = q.Where("? IS NOT NULL", bun.Ident("account.silenced_at"))
|
||||
|
||||
case "suspended":
|
||||
// Get only suspended accounts.
|
||||
q = q.Where("? IS NOT NULL", bun.Ident("account.suspended_at"))
|
||||
}
|
||||
|
||||
if mods {
|
||||
// Get only mod accounts.
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if *user.Moderator || *user.Admin {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
}
|
||||
useAccountIDIn = true
|
||||
}
|
||||
|
||||
// TODO: invitedBy
|
||||
|
||||
if username != "" {
|
||||
q = q.Where("? = ?", bun.Ident("account.username"), username)
|
||||
}
|
||||
|
||||
if displayName != "" {
|
||||
q = q.Where("? = ?", bun.Ident("account.display_name"), displayName)
|
||||
}
|
||||
|
||||
if domain != "" {
|
||||
q = q.Where("? = ?", bun.Ident("account.domain"), domain)
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.Email == email || user.UnconfirmedEmail == email {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
}
|
||||
useAccountIDIn = true
|
||||
}
|
||||
|
||||
// Use ip if not zero value.
|
||||
if ip.IsValid() {
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.SignUpIP.String() == ip.String() {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
}
|
||||
useAccountIDIn = true
|
||||
}
|
||||
|
||||
if origin == "local" && !useAccountIDIn {
|
||||
// In the case we're not already limiting
|
||||
// by specific subset of account IDs, just
|
||||
// use existing list of user.AccountIDs
|
||||
// instead of adding WHERE to the query.
|
||||
if err := lazyLoadUsers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
accountIDIn = append(accountIDIn, user.AccountID)
|
||||
}
|
||||
useAccountIDIn = true
|
||||
|
||||
} else if origin == "remote" {
|
||||
if useAccountIDIn {
|
||||
// useAccountIDIn specifically indicates
|
||||
// a parameter that limits querying to
|
||||
// local accounts, there will be none.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get only remote accounts.
|
||||
q = q.Where("? IS NOT NULL", bun.Ident("account.domain"))
|
||||
}
|
||||
|
||||
if useAccountIDIn {
|
||||
if len(accountIDIn) == 0 {
|
||||
// There will be no
|
||||
// possible answer.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
q = q.Where("? IN (?)", bun.Ident("account.id"), bun.In(accountIDIn))
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
// Limit amount of
|
||||
// accounts returned.
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if order == paging.OrderAscending {
|
||||
// Page up.
|
||||
q = q.Order("account.created_at ASC")
|
||||
} else {
|
||||
// Page down.
|
||||
q = q.Order("account.created_at DESC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &accountIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(accountIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want accounts
|
||||
// to be sorted by createdAt desc, so reverse ids slice.
|
||||
if order == paging.OrderAscending {
|
||||
slices.Reverse(accountIDs)
|
||||
}
|
||||
|
||||
// Return account IDs loaded from cache + db.
|
||||
return a.state.DB.GetAccountsByIDs(ctx, accountIDs)
|
||||
}
|
||||
|
||||
func (a *accountDB) getAccount(ctx context.Context, lookup string, dbQuery func(*gtsmodel.Account) error, keyParts ...any) (*gtsmodel.Account, error) {
|
||||
// Fetch account from database cache with loader callback
|
||||
account, err := a.state.Caches.GTS.Account.LoadOne(lookup, func() (*gtsmodel.Account, error) {
|
||||
|
|
|
@ -23,6 +23,7 @@ import (
|
|||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
@ -33,6 +34,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
@ -491,6 +493,189 @@ func (suite *AccountTestSuite) TestPopulateAccountWithUnknownMovedToURI() {
|
|||
suite.NoError(err)
|
||||
}
|
||||
|
||||
func (suite *AccountTestSuite) TestGetAccountsAll() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
origin = ""
|
||||
status = ""
|
||||
mods = false
|
||||
invitedBy = ""
|
||||
username = ""
|
||||
displayName = ""
|
||||
domain = ""
|
||||
email = ""
|
||||
ip netip.Addr
|
||||
page *paging.Page = nil
|
||||
)
|
||||
|
||||
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, 9)
|
||||
}
|
||||
|
||||
func (suite *AccountTestSuite) TestGetAccountsModsOnly() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
origin = ""
|
||||
status = ""
|
||||
mods = true
|
||||
invitedBy = ""
|
||||
username = ""
|
||||
displayName = ""
|
||||
domain = ""
|
||||
email = ""
|
||||
ip netip.Addr
|
||||
page = &paging.Page{
|
||||
Limit: 100,
|
||||
}
|
||||
)
|
||||
|
||||
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, 1)
|
||||
}
|
||||
|
||||
func (suite *AccountTestSuite) TestGetAccountsLocalWithEmail() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
origin = "local"
|
||||
status = ""
|
||||
mods = false
|
||||
invitedBy = ""
|
||||
username = ""
|
||||
displayName = ""
|
||||
domain = ""
|
||||
email = "tortle.dude@example.org"
|
||||
ip netip.Addr
|
||||
page = &paging.Page{
|
||||
Limit: 100,
|
||||
}
|
||||
)
|
||||
|
||||
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, 1)
|
||||
}
|
||||
|
||||
func (suite *AccountTestSuite) TestGetAccountsWithIP() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
origin = ""
|
||||
status = ""
|
||||
mods = false
|
||||
invitedBy = ""
|
||||
username = ""
|
||||
displayName = ""
|
||||
domain = ""
|
||||
email = ""
|
||||
ip = netip.MustParseAddr("199.222.111.89")
|
||||
page = &paging.Page{
|
||||
Limit: 100,
|
||||
}
|
||||
)
|
||||
|
||||
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, 1)
|
||||
}
|
||||
|
||||
func (suite *AccountTestSuite) TestGetPendingAccounts() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
origin = ""
|
||||
status = "pending"
|
||||
mods = false
|
||||
invitedBy = ""
|
||||
username = ""
|
||||
displayName = ""
|
||||
domain = ""
|
||||
email = ""
|
||||
ip netip.Addr
|
||||
page = &paging.Page{
|
||||
Limit: 100,
|
||||
}
|
||||
)
|
||||
|
||||
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, 1)
|
||||
}
|
||||
|
||||
func TestAccountTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AccountTestSuite))
|
||||
}
|
||||
|
|
|
@ -230,3 +230,23 @@ func (u *userDB) DeleteUserByID(ctx context.Context, userID string) error {
|
|||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *userDB) PutDeniedUser(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error {
|
||||
_, err := u.db.NewInsert().
|
||||
Model(deniedUser).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *userDB) GetDeniedUserByID(ctx context.Context, id string) (*gtsmodel.DeniedUser, error) {
|
||||
deniedUser := new(gtsmodel.DeniedUser)
|
||||
if err := u.db.
|
||||
NewSelect().
|
||||
Model(deniedUser).
|
||||
Where("? = ?", bun.Ident("denied_user.id"), id).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return deniedUser, nil
|
||||
}
|
||||
|
|
|
@ -54,4 +54,10 @@ type User interface {
|
|||
|
||||
// DeleteUserByID deletes one user by its ID.
|
||||
DeleteUserByID(ctx context.Context, userID string) error
|
||||
|
||||
// PutDeniedUser inserts the given deniedUser into the db.
|
||||
PutDeniedUser(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error
|
||||
|
||||
// GetDeniedUserByID returns one denied user with the given ID.
|
||||
GetDeniedUserByID(ctx context.Context, id string) (*gtsmodel.DeniedUser, error)
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ func (suite *EmailTestSuite) TestTemplateConfirm() {
|
|||
|
||||
suite.sender.SendConfirmEmail("user@example.org", confirmData)
|
||||
suite.Len(suite.sentEmails, 1)
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nTo use your account, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Email Confirmation\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because you've requested an account on https://example.org.\r\n\r\nTo use your account, you must confirm that this is your email address.\r\n\r\nTo confirm your email, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/confirm_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
}
|
||||
|
||||
func (suite *EmailTestSuite) TestTemplateReset() {
|
||||
|
@ -63,7 +63,7 @@ func (suite *EmailTestSuite) TestTemplateReset() {
|
|||
|
||||
suite.sender.SendResetEmail("user@example.org", resetData)
|
||||
suite.Len(suite.sentEmails, 1)
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Password Reset\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because a password reset has been requested for your account on https://example.org.\r\n\r\nTo reset your password, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Password Reset\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello test!\r\n\r\nYou are receiving this mail because a password reset has been requested for your account on https://example.org.\r\n\r\nTo reset your password, paste the following in your browser's address bar:\r\n\r\nhttps://example.org/reset_email?token=ee24f71d-e615-43f9-afae-385c0799b7fa\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
}
|
||||
|
||||
func (suite *EmailTestSuite) TestTemplateReportRemoteToLocal() {
|
||||
|
@ -166,7 +166,7 @@ func (suite *EmailTestSuite) TestTemplateReportClosedOK() {
|
|||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.Len(suite.sentEmails, 1)
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @foss_satan@fossbros-anonymous.io to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report left the following comment: User was yeeted. Thank you for reporting!\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @foss_satan@fossbros-anonymous.io to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report left the following comment: User was yeeted. Thank you for reporting!\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
}
|
||||
|
||||
func (suite *EmailTestSuite) TestTemplateReportClosedLocalAccountNoComment() {
|
||||
|
@ -182,7 +182,7 @@ func (suite *EmailTestSuite) TestTemplateReportClosedLocalAccountNoComment() {
|
|||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.Len(suite.sentEmails, 1)
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @1happyturtle to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report did not leave a comment.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
suite.Equal("To: user@example.org\r\nFrom: test@example.org\r\nSubject: GoToSocial Report Closed\r\nMIME-Version: 1.0\r\nContent-Transfer-Encoding: 8bit\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\nHello !\r\n\r\nYou recently reported the account @1happyturtle to the moderator(s) of Test Instance (https://example.org).\r\n\r\nThe report you submitted has now been closed.\r\n\r\nThe moderator who closed the report did not leave a comment.\r\n\r\n---\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of https://example.org.\r\n\r\n", suite.sentEmails["user@example.org"])
|
||||
}
|
||||
|
||||
func TestEmailTestSuite(t *testing.T) {
|
||||
|
|
|
@ -72,6 +72,14 @@ func (s *noopSender) SendNewSignupEmail(toAddresses []string, data NewSignupData
|
|||
return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...)
|
||||
}
|
||||
|
||||
func (s *noopSender) SendSignupApprovedEmail(toAddress string, data SignupApprovedData) error {
|
||||
return s.sendTemplate(signupApprovedTemplate, signupApprovedSubject, data, toAddress)
|
||||
}
|
||||
|
||||
func (s *noopSender) SendSignupRejectedEmail(toAddress string, data SignupRejectedData) error {
|
||||
return s.sendTemplate(signupRejectedTemplate, signupRejectedSubject, data, toAddress)
|
||||
}
|
||||
|
||||
func (s *noopSender) sendTemplate(template string, subject string, data any, toAddresses ...string) error {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := s.template.ExecuteTemplate(buf, template, data); err != nil {
|
||||
|
|
|
@ -53,6 +53,14 @@ type Sender interface {
|
|||
// It is expected that the toAddresses have already been filtered to ensure
|
||||
// that they all belong to active admins + moderators.
|
||||
SendNewSignupEmail(toAddress []string, data NewSignupData) error
|
||||
|
||||
// SendSignupApprovedEmail sends an email to the given address
|
||||
// that their sign-up request has been approved by a moderator.
|
||||
SendSignupApprovedEmail(toAddress string, data SignupApprovedData) error
|
||||
|
||||
// SendSignupRejectedEmail sends an email to the given address
|
||||
// that their sign-up request has been rejected by a moderator.
|
||||
SendSignupRejectedEmail(toAddress string, data SignupRejectedData) error
|
||||
}
|
||||
|
||||
// NewSender returns a new email Sender interface with the given configuration, or an error if something goes wrong.
|
||||
|
|
|
@ -40,3 +40,39 @@ type NewSignupData struct {
|
|||
func (s *sender) SendNewSignupEmail(toAddresses []string, data NewSignupData) error {
|
||||
return s.sendTemplate(newSignupTemplate, newSignupSubject, data, toAddresses...)
|
||||
}
|
||||
|
||||
var (
|
||||
signupApprovedTemplate = "email_signup_approved.tmpl"
|
||||
signupApprovedSubject = "GoToSocial Sign-Up Approved"
|
||||
)
|
||||
|
||||
type SignupApprovedData struct {
|
||||
// Username to be addressed.
|
||||
Username string
|
||||
// URL of the instance to present to the receiver.
|
||||
InstanceURL string
|
||||
// Name of the instance to present to the receiver.
|
||||
InstanceName string
|
||||
}
|
||||
|
||||
func (s *sender) SendSignupApprovedEmail(toAddress string, data SignupApprovedData) error {
|
||||
return s.sendTemplate(signupApprovedTemplate, signupApprovedSubject, data, toAddress)
|
||||
}
|
||||
|
||||
var (
|
||||
signupRejectedTemplate = "email_signup_rejected.tmpl"
|
||||
signupRejectedSubject = "GoToSocial Sign-Up Rejected"
|
||||
)
|
||||
|
||||
type SignupRejectedData struct {
|
||||
// Message to the rejected applicant.
|
||||
Message string
|
||||
// URL of the instance to present to the receiver.
|
||||
InstanceURL string
|
||||
// Name of the instance to present to the receiver.
|
||||
InstanceName string
|
||||
}
|
||||
|
||||
func (s *sender) SendSignupRejectedEmail(toAddress string, data SignupRejectedData) error {
|
||||
return s.sendTemplate(signupRejectedTemplate, signupRejectedSubject, data, toAddress)
|
||||
}
|
||||
|
|
79
internal/processing/admin/accountapprove.go
Normal file
79
internal/processing/admin/accountapprove.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||
)
|
||||
|
||||
func (p *Processor) AccountApprove(
|
||||
ctx context.Context,
|
||||
adminAcct *gtsmodel.Account,
|
||||
accountID string,
|
||||
) (*apimodel.AdminAccountInfo, gtserror.WithCode) {
|
||||
user, err := p.state.DB.GetUserByAccountID(ctx, accountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
err := fmt.Errorf("user for account %s not found", accountID)
|
||||
return nil, gtserror.NewErrorNotFound(err, err.Error())
|
||||
}
|
||||
|
||||
// Get a lock on the account URI,
|
||||
// to ensure it's not also being
|
||||
// rejected at the same time!
|
||||
unlock := p.state.ClientLocks.Lock(user.Account.URI)
|
||||
defer unlock()
|
||||
|
||||
if !*user.Approved {
|
||||
// Process approval side effects asynschronously.
|
||||
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
|
||||
APObjectType: ap.ActorPerson,
|
||||
APActivityType: ap.ActivityAccept,
|
||||
GTSModel: user,
|
||||
OriginAccount: adminAcct,
|
||||
TargetAccount: user.Account,
|
||||
})
|
||||
}
|
||||
|
||||
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Optimistically set approved to true and
|
||||
// clear sign-up IP to reflect state that
|
||||
// will be produced by side effects.
|
||||
apiAccount.Approved = true
|
||||
apiAccount.IP = nil
|
||||
|
||||
return apiAccount, nil
|
||||
}
|
75
internal/processing/admin/accountapprove_test.go
Normal file
75
internal/processing/admin/accountapprove_test.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type AdminApproveTestSuite struct {
|
||||
AdminStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *AdminApproveTestSuite) TestApprove() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
adminAcct = suite.testAccounts["admin_account"]
|
||||
targetAcct = suite.testAccounts["unconfirmed_account"]
|
||||
targetUser = new(gtsmodel.User)
|
||||
)
|
||||
|
||||
// Copy user since we're modifying it.
|
||||
*targetUser = *suite.testUsers["unconfirmed_account"]
|
||||
|
||||
// Approve the sign-up.
|
||||
acct, errWithCode := suite.adminProcessor.AccountApprove(
|
||||
ctx,
|
||||
adminAcct,
|
||||
targetAcct.ID,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
suite.FailNow(errWithCode.Error())
|
||||
}
|
||||
|
||||
// Account should be approved.
|
||||
suite.NotNil(acct)
|
||||
suite.True(acct.Approved)
|
||||
suite.Nil(acct.IP)
|
||||
|
||||
// Wait for processor to
|
||||
// handle side effects.
|
||||
var (
|
||||
dbUser *gtsmodel.User
|
||||
err error
|
||||
)
|
||||
if !testrig.WaitFor(func() bool {
|
||||
dbUser, err = suite.state.DB.GetUserByID(ctx, targetUser.ID)
|
||||
return err == nil && dbUser != nil && *dbUser.Approved
|
||||
}) {
|
||||
suite.FailNow("waiting for approved user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminApproveTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AdminApproveTestSuite))
|
||||
}
|
49
internal/processing/admin/accountget.go
Normal file
49
internal/processing/admin/accountget.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
func (p *Processor) AccountGet(ctx context.Context, accountID string) (*apimodel.AdminAccountInfo, gtserror.WithCode) {
|
||||
account, err := p.state.DB.GetAccountByID(ctx, accountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting account %s: %w", accountID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
err := fmt.Errorf("account %s not found", accountID)
|
||||
return nil, gtserror.NewErrorNotFound(err, err.Error())
|
||||
}
|
||||
|
||||
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, account)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return apiAccount, nil
|
||||
}
|
113
internal/processing/admin/accountreject.go
Normal file
113
internal/processing/admin/accountreject.go
Normal file
|
@ -0,0 +1,113 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||
)
|
||||
|
||||
func (p *Processor) AccountReject(
|
||||
ctx context.Context,
|
||||
adminAcct *gtsmodel.Account,
|
||||
accountID string,
|
||||
privateComment string,
|
||||
sendEmail bool,
|
||||
message string,
|
||||
) (*apimodel.AdminAccountInfo, gtserror.WithCode) {
|
||||
user, err := p.state.DB.GetUserByAccountID(ctx, accountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting user for account id %s: %w", accountID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
err := fmt.Errorf("user for account %s not found", accountID)
|
||||
return nil, gtserror.NewErrorNotFound(err, err.Error())
|
||||
}
|
||||
|
||||
// Get a lock on the account URI,
|
||||
// since we're going to be deleting
|
||||
// it and its associated user.
|
||||
unlock := p.state.ClientLocks.Lock(user.Account.URI)
|
||||
defer unlock()
|
||||
|
||||
// Can't reject an account with a
|
||||
// user that's already been approved.
|
||||
if *user.Approved {
|
||||
err := fmt.Errorf("account %s has already been approved", accountID)
|
||||
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||
}
|
||||
|
||||
// Convert to API account *before* doing the
|
||||
// rejection, since the rejection will cause
|
||||
// the user and account to be removed.
|
||||
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, user.Account)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting account %s to admin api model: %w", accountID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Set approved to false on the API model, to
|
||||
// reflect the changes that will occur
|
||||
// asynchronously in the processor.
|
||||
apiAccount.Approved = false
|
||||
|
||||
// Ensure we an email address.
|
||||
var email string
|
||||
if user.Email != "" {
|
||||
email = user.Email
|
||||
} else {
|
||||
email = user.UnconfirmedEmail
|
||||
}
|
||||
|
||||
// Create a denied user entry for
|
||||
// the worker to process + store.
|
||||
deniedUser := >smodel.DeniedUser{
|
||||
ID: user.ID,
|
||||
Email: email,
|
||||
Username: user.Account.Username,
|
||||
SignUpIP: user.SignUpIP,
|
||||
InviteID: user.InviteID,
|
||||
Locale: user.Locale,
|
||||
CreatedByApplicationID: user.CreatedByApplicationID,
|
||||
SignUpReason: user.Reason,
|
||||
PrivateComment: privateComment,
|
||||
SendEmail: &sendEmail,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
// Process rejection side effects asynschronously.
|
||||
p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
|
||||
APObjectType: ap.ActorPerson,
|
||||
APActivityType: ap.ActivityReject,
|
||||
GTSModel: deniedUser,
|
||||
OriginAccount: adminAcct,
|
||||
TargetAccount: user.Account,
|
||||
})
|
||||
|
||||
return apiAccount, nil
|
||||
}
|
142
internal/processing/admin/accountreject_test.go
Normal file
142
internal/processing/admin/accountreject_test.go
Normal file
|
@ -0,0 +1,142 @@
|
|||
// 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 (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type AdminRejectTestSuite struct {
|
||||
AdminStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *AdminRejectTestSuite) TestReject() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
adminAcct = suite.testAccounts["admin_account"]
|
||||
targetAcct = suite.testAccounts["unconfirmed_account"]
|
||||
targetUser = suite.testUsers["unconfirmed_account"]
|
||||
privateComment = "It's a no from me chief."
|
||||
sendEmail = true
|
||||
message = "Too stinky."
|
||||
)
|
||||
|
||||
acct, errWithCode := suite.adminProcessor.AccountReject(
|
||||
ctx,
|
||||
adminAcct,
|
||||
targetAcct.ID,
|
||||
privateComment,
|
||||
sendEmail,
|
||||
message,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
suite.FailNow(errWithCode.Error())
|
||||
}
|
||||
suite.NotNil(acct)
|
||||
suite.False(acct.Approved)
|
||||
|
||||
// Wait for processor to
|
||||
// handle side effects.
|
||||
var (
|
||||
deniedUser *gtsmodel.DeniedUser
|
||||
err error
|
||||
)
|
||||
if !testrig.WaitFor(func() bool {
|
||||
deniedUser, err = suite.state.DB.GetDeniedUserByID(ctx, targetUser.ID)
|
||||
return deniedUser != nil && err == nil
|
||||
}) {
|
||||
suite.FailNow("waiting for denied user")
|
||||
}
|
||||
|
||||
// Ensure fields as expected.
|
||||
suite.Equal(targetUser.ID, deniedUser.ID)
|
||||
suite.Equal(targetUser.UnconfirmedEmail, deniedUser.Email)
|
||||
suite.Equal(targetAcct.Username, deniedUser.Username)
|
||||
suite.Equal(targetUser.SignUpIP, deniedUser.SignUpIP)
|
||||
suite.Equal(targetUser.InviteID, deniedUser.InviteID)
|
||||
suite.Equal(targetUser.Locale, deniedUser.Locale)
|
||||
suite.Equal(targetUser.CreatedByApplicationID, deniedUser.CreatedByApplicationID)
|
||||
suite.Equal(targetUser.Reason, deniedUser.SignUpReason)
|
||||
suite.Equal(privateComment, deniedUser.PrivateComment)
|
||||
suite.Equal(sendEmail, *deniedUser.SendEmail)
|
||||
suite.Equal(message, deniedUser.Message)
|
||||
|
||||
// Should be no user entry for
|
||||
// this denied request now.
|
||||
_, err = suite.state.DB.GetUserByID(ctx, targetUser.ID)
|
||||
suite.ErrorIs(db.ErrNoEntries, err)
|
||||
|
||||
// Should be no account entry for
|
||||
// this denied request now.
|
||||
_, err = suite.state.DB.GetAccountByID(ctx, targetAcct.ID)
|
||||
suite.ErrorIs(db.ErrNoEntries, err)
|
||||
}
|
||||
|
||||
func (suite *AdminRejectTestSuite) TestRejectRemote() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
adminAcct = suite.testAccounts["admin_account"]
|
||||
targetAcct = suite.testAccounts["remote_account_1"]
|
||||
privateComment = "It's a no from me chief."
|
||||
sendEmail = true
|
||||
message = "Too stinky."
|
||||
)
|
||||
|
||||
// Try to reject a remote account.
|
||||
_, err := suite.adminProcessor.AccountReject(
|
||||
ctx,
|
||||
adminAcct,
|
||||
targetAcct.ID,
|
||||
privateComment,
|
||||
sendEmail,
|
||||
message,
|
||||
)
|
||||
suite.EqualError(err, "user for account 01F8MH5ZK5VRH73AKHQM6Y9VNX not found")
|
||||
}
|
||||
|
||||
func (suite *AdminRejectTestSuite) TestRejectApproved() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
adminAcct = suite.testAccounts["admin_account"]
|
||||
targetAcct = suite.testAccounts["local_account_1"]
|
||||
privateComment = "It's a no from me chief."
|
||||
sendEmail = true
|
||||
message = "Too stinky."
|
||||
)
|
||||
|
||||
// Try to reject an already-approved account.
|
||||
_, err := suite.adminProcessor.AccountReject(
|
||||
ctx,
|
||||
adminAcct,
|
||||
targetAcct.ID,
|
||||
privateComment,
|
||||
sendEmail,
|
||||
message,
|
||||
)
|
||||
suite.EqualError(err, "account 01F8MH1H7YV1Z7D2C8K2730QBF has already been approved")
|
||||
}
|
||||
|
||||
func TestAdminRejectTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AdminRejectTestSuite))
|
||||
}
|
272
internal/processing/admin/accounts.go
Normal file
272
internal/processing/admin/accounts.go
Normal file
|
@ -0,0 +1,272 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
var (
|
||||
accountsValidOrigins = []string{"local", "remote"}
|
||||
accountsValidStatuses = []string{"active", "pending", "disabled", "silenced", "suspended"}
|
||||
accountsValidPermissions = []string{"staff"}
|
||||
)
|
||||
|
||||
func (p *Processor) AccountsGet(
|
||||
ctx context.Context,
|
||||
request *apimodel.AdminGetAccountsRequest,
|
||||
page *paging.Page,
|
||||
) (
|
||||
*apimodel.PageableResponse,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
// Validate "origin".
|
||||
if v := request.Origin; v != "" {
|
||||
if !slices.Contains(accountsValidOrigins, v) {
|
||||
err := fmt.Errorf(
|
||||
"origin %s not recognized; valid choices are %+v",
|
||||
v, accountsValidOrigins,
|
||||
)
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Validate "status".
|
||||
if v := request.Status; v != "" {
|
||||
if !slices.Contains(accountsValidStatuses, v) {
|
||||
err := fmt.Errorf(
|
||||
"status %s not recognized; valid choices are %+v",
|
||||
v, accountsValidStatuses,
|
||||
)
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Validate "permissions".
|
||||
if v := request.Permissions; v != "" {
|
||||
if !slices.Contains(accountsValidPermissions, v) {
|
||||
err := fmt.Errorf(
|
||||
"permissions %s not recognized; valid choices are %+v",
|
||||
v, accountsValidPermissions,
|
||||
)
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Validate/parse IP.
|
||||
var ip netip.Addr
|
||||
if v := request.IP; v != "" {
|
||||
var err error
|
||||
ip, err = netip.ParseAddr(request.IP)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("invalid ip provided: %w", err)
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Get accounts with the given params.
|
||||
accounts, err := p.state.DB.GetAccounts(
|
||||
ctx,
|
||||
request.Origin,
|
||||
request.Status,
|
||||
func() bool { return request.Permissions == "staff" }(),
|
||||
request.InvitedBy,
|
||||
request.Username,
|
||||
request.DisplayName,
|
||||
request.ByDomain,
|
||||
request.Email,
|
||||
ip,
|
||||
page,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = gtserror.Newf("db error getting accounts: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(accounts)
|
||||
if count == 0 {
|
||||
return paging.EmptyResponse(), nil
|
||||
}
|
||||
|
||||
hi := accounts[count-1].ID
|
||||
lo := accounts[0].ID
|
||||
|
||||
items := make([]interface{}, 0, count)
|
||||
for _, account := range accounts {
|
||||
apiAccount, err := p.converter.AccountToAdminAPIAccount(ctx, account)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error converting to api account: %v", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, apiAccount)
|
||||
}
|
||||
|
||||
// Return packaging + paging appropriate for
|
||||
// the API version used to call this function.
|
||||
switch request.APIVersion {
|
||||
case 1:
|
||||
return packageAccountsV1(items, lo, hi, request, page)
|
||||
|
||||
case 2:
|
||||
return packageAccountsV2(items, lo, hi, request, page)
|
||||
|
||||
default:
|
||||
log.Panic(ctx, "api version was neither 1 nor 2")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func packageAccountsV1(
|
||||
items []interface{},
|
||||
loID, hiID string,
|
||||
request *apimodel.AdminGetAccountsRequest,
|
||||
page *paging.Page,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
queryParams := make(url.Values, 8)
|
||||
|
||||
// Translate origin to v1.
|
||||
if v := request.Origin; v != "" {
|
||||
var k string
|
||||
|
||||
if v == "local" {
|
||||
k = apiutil.LocalKey
|
||||
} else {
|
||||
k = apiutil.AdminRemoteKey
|
||||
}
|
||||
|
||||
queryParams.Add(k, "true")
|
||||
}
|
||||
|
||||
// Translate status to v1.
|
||||
if v := request.Status; v != "" {
|
||||
var k string
|
||||
|
||||
switch v {
|
||||
case "active":
|
||||
k = apiutil.AdminActiveKey
|
||||
case "pending":
|
||||
k = apiutil.AdminPendingKey
|
||||
case "disabled":
|
||||
k = apiutil.AdminDisabledKey
|
||||
case "silenced":
|
||||
k = apiutil.AdminSilencedKey
|
||||
case "suspended":
|
||||
k = apiutil.AdminSuspendedKey
|
||||
}
|
||||
|
||||
queryParams.Add(k, "true")
|
||||
}
|
||||
|
||||
if v := request.Username; v != "" {
|
||||
queryParams.Add(apiutil.UsernameKey, v)
|
||||
}
|
||||
|
||||
if v := request.DisplayName; v != "" {
|
||||
queryParams.Add(apiutil.AdminDisplayNameKey, v)
|
||||
}
|
||||
|
||||
if v := request.ByDomain; v != "" {
|
||||
queryParams.Add(apiutil.AdminByDomainKey, v)
|
||||
}
|
||||
|
||||
if v := request.Email; v != "" {
|
||||
queryParams.Add(apiutil.AdminEmailKey, v)
|
||||
}
|
||||
|
||||
if v := request.IP; v != "" {
|
||||
queryParams.Add(apiutil.AdminIPKey, v)
|
||||
}
|
||||
|
||||
// Translate permissions to v1.
|
||||
if v := request.Permissions; v != "" {
|
||||
queryParams.Add(apiutil.AdminStaffKey, v)
|
||||
}
|
||||
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/admin/accounts",
|
||||
Next: page.Next(loID, hiID),
|
||||
Prev: page.Prev(loID, hiID),
|
||||
Query: queryParams,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func packageAccountsV2(
|
||||
items []interface{},
|
||||
loID, hiID string,
|
||||
request *apimodel.AdminGetAccountsRequest,
|
||||
page *paging.Page,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
queryParams := make(url.Values, 9)
|
||||
|
||||
if v := request.Origin; v != "" {
|
||||
queryParams.Add(apiutil.AdminOriginKey, v)
|
||||
}
|
||||
|
||||
if v := request.Status; v != "" {
|
||||
queryParams.Add(apiutil.AdminStatusKey, v)
|
||||
}
|
||||
|
||||
if v := request.Permissions; v != "" {
|
||||
queryParams.Add(apiutil.AdminPermissionsKey, v)
|
||||
}
|
||||
|
||||
if v := request.InvitedBy; v != "" {
|
||||
queryParams.Add(apiutil.AdminInvitedByKey, v)
|
||||
}
|
||||
|
||||
if v := request.Username; v != "" {
|
||||
queryParams.Add(apiutil.UsernameKey, v)
|
||||
}
|
||||
|
||||
if v := request.DisplayName; v != "" {
|
||||
queryParams.Add(apiutil.AdminDisplayNameKey, v)
|
||||
}
|
||||
|
||||
if v := request.ByDomain; v != "" {
|
||||
queryParams.Add(apiutil.AdminByDomainKey, v)
|
||||
}
|
||||
|
||||
if v := request.Email; v != "" {
|
||||
queryParams.Add(apiutil.AdminEmailKey, v)
|
||||
}
|
||||
|
||||
if v := request.IP; v != "" {
|
||||
queryParams.Add(apiutil.AdminIPKey, v)
|
||||
}
|
||||
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v2/admin/accounts",
|
||||
Next: page.Next(loID, hiID),
|
||||
Prev: page.Prev(loID, hiID),
|
||||
Query: queryParams,
|
||||
}), nil
|
||||
}
|
|
@ -33,6 +33,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// clientAPI wraps processing functions
|
||||
|
@ -141,6 +142,10 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From
|
|||
// ACCEPT FOLLOW (request)
|
||||
case ap.ActivityFollow:
|
||||
return p.clientAPI.AcceptFollow(ctx, cMsg)
|
||||
|
||||
// ACCEPT PROFILE/ACCOUNT (sign-up)
|
||||
case ap.ObjectProfile, ap.ActorPerson:
|
||||
return p.clientAPI.AcceptAccount(ctx, cMsg)
|
||||
}
|
||||
|
||||
// REJECT SOMETHING
|
||||
|
@ -150,6 +155,10 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From
|
|||
// REJECT FOLLOW (request)
|
||||
case ap.ActivityFollow:
|
||||
return p.clientAPI.RejectFollowRequest(ctx, cMsg)
|
||||
|
||||
// REJECT PROFILE/ACCOUNT (sign-up)
|
||||
case ap.ObjectProfile, ap.ActorPerson:
|
||||
return p.clientAPI.RejectAccount(ctx, cMsg)
|
||||
}
|
||||
|
||||
// UNDO SOMETHING
|
||||
|
@ -685,3 +694,66 @@ func (p *clientAPI) MoveAccount(ctx context.Context, cMsg messages.FromClientAPI
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) AcceptAccount(ctx context.Context, cMsg messages.FromClientAPI) error {
|
||||
newUser, ok := cMsg.GTSModel.(*gtsmodel.User)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.User", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Mark user as approved + clear sign-up IP.
|
||||
newUser.Approved = util.Ptr(true)
|
||||
newUser.SignUpIP = nil
|
||||
if err := p.state.DB.UpdateUser(ctx, newUser, "approved", "sign_up_ip"); err != nil {
|
||||
// Error now means we should return without
|
||||
// sending email + let admin try to approve again.
|
||||
return gtserror.Newf("db error updating user %s: %w", newUser.ID, err)
|
||||
}
|
||||
|
||||
// Send "your sign-up has been approved" email to the new user.
|
||||
if err := p.surface.emailUserSignupApproved(ctx, newUser); err != nil {
|
||||
log.Errorf(ctx, "error emailing: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) RejectAccount(ctx context.Context, cMsg messages.FromClientAPI) error {
|
||||
deniedUser, ok := cMsg.GTSModel.(*gtsmodel.DeniedUser)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.DeniedUser", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Remove the account.
|
||||
if err := p.state.DB.DeleteAccount(ctx, cMsg.TargetAccount.ID); err != nil {
|
||||
log.Errorf(ctx,
|
||||
"db error deleting account %s: %v",
|
||||
cMsg.TargetAccount.ID, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the user.
|
||||
if err := p.state.DB.DeleteUserByID(ctx, deniedUser.ID); err != nil {
|
||||
log.Errorf(ctx,
|
||||
"db error deleting user %s: %v",
|
||||
deniedUser.ID, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Store the deniedUser entry.
|
||||
if err := p.state.DB.PutDeniedUser(ctx, deniedUser); err != nil {
|
||||
log.Errorf(ctx,
|
||||
"db error putting denied user %s: %v",
|
||||
deniedUser.ID, err,
|
||||
)
|
||||
}
|
||||
|
||||
if *deniedUser.SendEmail {
|
||||
// Send "your sign-up has been rejected" email to the denied user.
|
||||
if err := p.surface.emailUserSignupRejected(ctx, deniedUser); err != nil {
|
||||
log.Errorf(ctx, "error emailing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -129,6 +129,69 @@ func (s *surface) emailUserPleaseConfirm(ctx context.Context, user *gtsmodel.Use
|
|||
return nil
|
||||
}
|
||||
|
||||
// emailUserSignupApproved emails the given user
|
||||
// to inform them their sign-up has been approved.
|
||||
func (s *surface) emailUserSignupApproved(ctx context.Context, user *gtsmodel.User) error {
|
||||
// User may have been approved without
|
||||
// their email address being confirmed
|
||||
// yet. Just send to whatever we have.
|
||||
emailAddr := user.Email
|
||||
if emailAddr == "" {
|
||||
emailAddr = user.UnconfirmedEmail
|
||||
}
|
||||
|
||||
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting instance: %w", err)
|
||||
}
|
||||
|
||||
// Assemble email contents and send the email.
|
||||
if err := s.emailSender.SendSignupApprovedEmail(
|
||||
emailAddr,
|
||||
email.SignupApprovedData{
|
||||
Username: user.Account.Username,
|
||||
InstanceURL: instance.URI,
|
||||
InstanceName: instance.Title,
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Email sent, update the user
|
||||
// entry with the emailed time.
|
||||
now := time.Now()
|
||||
user.LastEmailedAt = now
|
||||
|
||||
if err := s.state.DB.UpdateUser(
|
||||
ctx,
|
||||
user,
|
||||
"last_emailed_at",
|
||||
); err != nil {
|
||||
return gtserror.Newf("error updating user entry after email sent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// emailUserSignupApproved emails the given user
|
||||
// to inform them their sign-up has been approved.
|
||||
func (s *surface) emailUserSignupRejected(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error {
|
||||
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting instance: %w", err)
|
||||
}
|
||||
|
||||
// Assemble email contents and send the email.
|
||||
return s.emailSender.SendSignupRejectedEmail(
|
||||
deniedUser.Email,
|
||||
email.SignupRejectedData{
|
||||
Message: deniedUser.Message,
|
||||
InstanceURL: instance.URI,
|
||||
InstanceName: instance.Title,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// emailAdminReportOpened emails all active moderators/admins
|
||||
// of this instance that a new report has been created.
|
||||
func (s *surface) emailAdminReportOpened(ctx context.Context, report *gtsmodel.Report) error {
|
||||
|
@ -193,7 +256,7 @@ func (s *surface) emailAdminNewSignup(ctx context.Context, newUser *gtsmodel.Use
|
|||
SignupEmail: newUser.UnconfirmedEmail,
|
||||
SignupUsername: newUser.Account.Username,
|
||||
SignupReason: newUser.Reason,
|
||||
SignupURL: "TODO",
|
||||
SignupURL: instance.URI + "/settings/admin/accounts/" + newUser.AccountID,
|
||||
}
|
||||
|
||||
if err := s.emailSender.SendNewSignupEmail(toAddresses, newSignupData); err != nil {
|
||||
|
|
|
@ -34,7 +34,7 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
|
||||
targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -49,7 +49,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Parse account targetUsername from the URL.
|
||||
targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
|
||||
targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
|
||||
return
|
||||
|
|
|
@ -38,7 +38,7 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Fetch + normalize username from URL.
|
||||
username, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
|
||||
username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -50,7 +50,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Parse account targetUsername and status ID from the URL.
|
||||
targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
|
||||
targetUsername, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
|
||||
return
|
||||
|
|
|
@ -115,6 +115,7 @@ nav:
|
|||
|
||||
- "Admin":
|
||||
- "admin/settings.md"
|
||||
- "admin/signups.md"
|
||||
- "admin/federation_modes.md"
|
||||
- "admin/domain_blocks.md"
|
||||
- "admin/cli.md"
|
||||
|
|
|
@ -130,10 +130,11 @@ main {
|
|||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
color: $white2;
|
||||
background: $gray2;
|
||||
cursor: auto;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: $gray3;
|
||||
|
|
|
@ -1,112 +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/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { useRoute, Redirect } = require("wouter");
|
||||
|
||||
const query = require("../../lib/query");
|
||||
|
||||
const FormWithData = require("../../lib/form/form-with-data").default;
|
||||
|
||||
const { useBaseUrl } = require("../../lib/navigation/util");
|
||||
const FakeProfile = require("../../components/fake-profile");
|
||||
const MutationButton = require("../../components/form/mutation-button");
|
||||
|
||||
const useFormSubmit = require("../../lib/form/submit").default;
|
||||
const { useValue, useTextInput } = require("../../lib/form");
|
||||
const { TextInput } = require("../../components/form/inputs");
|
||||
|
||||
module.exports = function AccountDetail({ }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
let [_match, params] = useRoute(`${baseUrl}/:accountId`);
|
||||
|
||||
if (params?.accountId == undefined) {
|
||||
return <Redirect to={baseUrl} />;
|
||||
} else {
|
||||
return (
|
||||
<div className="account-detail">
|
||||
<h1>
|
||||
Account Details
|
||||
</h1>
|
||||
<FormWithData
|
||||
dataQuery={query.useGetAccountQuery}
|
||||
queryArg={params.accountId}
|
||||
DataForm={AccountDetailForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function AccountDetailForm({ data: account }) {
|
||||
let content;
|
||||
if (account.suspended) {
|
||||
content = (
|
||||
<h2 className="error">Account is suspended.</h2>
|
||||
);
|
||||
} else {
|
||||
content = <ModifyAccount account={account} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FakeProfile {...account} />
|
||||
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModifyAccount({ account }) {
|
||||
const form = {
|
||||
id: useValue("id", account.id),
|
||||
reason: useTextInput("text")
|
||||
};
|
||||
|
||||
const [modifyAccount, result] = useFormSubmit(form, query.useActionAccountMutation());
|
||||
|
||||
return (
|
||||
<form onSubmit={modifyAccount}>
|
||||
<h2>Actions</h2>
|
||||
<TextInput
|
||||
field={form.reason}
|
||||
placeholder="Reason for this action"
|
||||
/>
|
||||
|
||||
<div className="action-buttons">
|
||||
{/* <MutationButton
|
||||
label="Disable"
|
||||
name="disable"
|
||||
result={result}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Silence"
|
||||
name="silence"
|
||||
result={result}
|
||||
/> */}
|
||||
<MutationButton
|
||||
label="Suspend"
|
||||
name="suspend"
|
||||
result={result}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
89
web/source/settings/admin/accounts/detail/actions.tsx
Normal file
89
web/source/settings/admin/accounts/detail/actions.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
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 { useActionAccountMutation } from "../../../lib/query";
|
||||
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import {
|
||||
useValue,
|
||||
useTextInput,
|
||||
useBoolInput,
|
||||
} from "../../../lib/form";
|
||||
|
||||
import { Checkbox, TextInput } from "../../../components/form/inputs";
|
||||
import { AdminAccount } from "../../../lib/types/account";
|
||||
|
||||
export interface AccountActionsProps {
|
||||
account: AdminAccount,
|
||||
}
|
||||
|
||||
export function AccountActions({ account }: AccountActionsProps) {
|
||||
const form = {
|
||||
id: useValue("id", account.id),
|
||||
reason: useTextInput("text")
|
||||
};
|
||||
|
||||
const reallySuspend = useBoolInput("reallySuspend");
|
||||
const [accountAction, result] = useFormSubmit(form, useActionAccountMutation());
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={accountAction}
|
||||
aria-labelledby="account-moderation-actions"
|
||||
>
|
||||
<h3 id="account-moderation-actions">Account Moderation Actions</h3>
|
||||
<div>
|
||||
Currently only the "suspend" action is implemented.<br/>
|
||||
Suspending an account will delete it from your server, and remove all of its media, posts, relationships, etc.<br/>
|
||||
If the suspended account is local, suspending will also send out a "delete" message to other servers, requesting them to remove its data from their instance as well.<br/>
|
||||
<b>Account suspension cannot be reversed.</b>
|
||||
</div>
|
||||
<TextInput
|
||||
field={form.reason}
|
||||
placeholder="Reason for this action"
|
||||
/>
|
||||
<div className="action-buttons">
|
||||
{/* <MutationButton
|
||||
label="Disable"
|
||||
name="disable"
|
||||
result={result}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Silence"
|
||||
name="silence"
|
||||
result={result}
|
||||
/> */}
|
||||
<MutationButton
|
||||
disabled={account.suspended || reallySuspend.value === undefined || reallySuspend.value === false}
|
||||
label="Suspend"
|
||||
name="suspend"
|
||||
result={result}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Really suspend"
|
||||
field={reallySuspend}
|
||||
></Checkbox>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
118
web/source/settings/admin/accounts/detail/handlesignup.tsx
Normal file
118
web/source/settings/admin/accounts/detail/handlesignup.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
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";
|
||||
|
||||
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,
|
||||
accountsBaseUrl: string,
|
||||
}
|
||||
|
||||
export function HandleSignup({account, accountsBaseUrl}: 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(accountsBaseUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
179
web/source/settings/admin/accounts/detail/index.tsx
Normal file
179
web/source/settings/admin/accounts/detail/index.tsx
Normal file
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
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 { useRoute, Redirect } from "wouter";
|
||||
|
||||
import { useGetAccountQuery } from "../../../lib/query";
|
||||
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
import FakeProfile from "../../../components/fake-profile";
|
||||
|
||||
import { AdminAccount } from "../../../lib/types/account";
|
||||
import { HandleSignup } from "./handlesignup";
|
||||
import { AccountActions } from "./actions";
|
||||
import BackButton from "../../../components/back-button";
|
||||
|
||||
export default function AccountDetail() {
|
||||
// /settings/admin/accounts
|
||||
const accountsBaseUrl = useBaseUrl();
|
||||
|
||||
let [_match, params] = useRoute(`${accountsBaseUrl}/:accountId`);
|
||||
|
||||
if (params?.accountId == undefined) {
|
||||
return <Redirect to={accountsBaseUrl} />;
|
||||
} else {
|
||||
return (
|
||||
<div className="account-detail">
|
||||
<h1 className="text-cutoff">
|
||||
<BackButton to={accountsBaseUrl} /> Account Details
|
||||
</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetAccountQuery}
|
||||
queryArg={params.accountId}
|
||||
DataForm={AccountDetailForm}
|
||||
{...{accountsBaseUrl}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountDetailFormProps {
|
||||
accountsBaseUrl: string,
|
||||
data: AdminAccount,
|
||||
}
|
||||
|
||||
function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFormProps) {
|
||||
let yesOrNo = (b: boolean) => {
|
||||
return b ? "yes" : "no";
|
||||
};
|
||||
|
||||
let created = new Date(adminAcct.created_at).toDateString();
|
||||
let lastPosted = "never";
|
||||
if (adminAcct.account.last_status_at) {
|
||||
lastPosted = new Date(adminAcct.account.last_status_at).toDateString();
|
||||
}
|
||||
const local = !adminAcct.domain;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FakeProfile {...adminAcct.account} />
|
||||
<h3>General Account Details</h3>
|
||||
{ adminAcct.suspended &&
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>Account is suspended.</b>
|
||||
</div>
|
||||
}
|
||||
<dl className="info-list">
|
||||
{ !local &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Domain</dt>
|
||||
<dd>{adminAcct.domain}</dd>
|
||||
</div>}
|
||||
<div className="info-list-entry">
|
||||
<dt>Created</dt>
|
||||
<dd><time dateTime={adminAcct.created_at}>{created}</time></dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Last posted</dt>
|
||||
<dd>{lastPosted}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Suspended</dt>
|
||||
<dd>{yesOrNo(adminAcct.suspended)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Silenced</dt>
|
||||
<dd>{yesOrNo(adminAcct.silenced)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Statuses</dt>
|
||||
<dd>{adminAcct.account.statuses_count}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Followers</dt>
|
||||
<dd>{adminAcct.account.followers_count}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Following</dt>
|
||||
<dd>{adminAcct.account.following_count}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{ local &&
|
||||
// Only show local account details
|
||||
// if this is a local account!
|
||||
<>
|
||||
<h3>Local Account Details</h3>
|
||||
{ !adminAcct.approved &&
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>Account is pending.</b>
|
||||
</div>
|
||||
}
|
||||
{ !adminAcct.confirmed &&
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>Account email not yet confirmed.</b>
|
||||
</div>
|
||||
}
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>Email</dt>
|
||||
<dd>{adminAcct.email} {<b>{adminAcct.confirmed ? "(confirmed)" : "(not confirmed)"}</b> }</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Disabled</dt>
|
||||
<dd>{yesOrNo(adminAcct.disabled)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Approved</dt>
|
||||
<dd>{yesOrNo(adminAcct.approved)}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Sign-Up Reason</dt>
|
||||
<dd>{adminAcct.invite_request ?? <i>none provided</i>}</dd>
|
||||
</div>
|
||||
{ (adminAcct.ip && adminAcct.ip !== "0.0.0.0") &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Sign-Up IP</dt>
|
||||
<dd>{adminAcct.ip}</dd>
|
||||
</div> }
|
||||
{ adminAcct.locale &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Locale</dt>
|
||||
<dd>{adminAcct.locale}</dd>
|
||||
</div> }
|
||||
</dl>
|
||||
</> }
|
||||
{ local && !adminAcct.approved
|
||||
?
|
||||
<HandleSignup
|
||||
account={adminAcct}
|
||||
accountsBaseUrl={accountsBaseUrl}
|
||||
/>
|
||||
:
|
||||
<AccountActions account={adminAcct} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,138 +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/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Switch, Route, Link } = require("wouter");
|
||||
|
||||
const query = require("../../lib/query");
|
||||
const { useTextInput } = require("../../lib/form");
|
||||
|
||||
const AccountDetail = require("./detail");
|
||||
const { useBaseUrl } = require("../../lib/navigation/util");
|
||||
const { Error } = require("../../components/error");
|
||||
|
||||
module.exports = function Accounts({ baseUrl }) {
|
||||
return (
|
||||
<div className="accounts">
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/:accountId`}>
|
||||
<AccountDetail />
|
||||
</Route>
|
||||
<AccountOverview />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function AccountOverview({ }) {
|
||||
return (
|
||||
<>
|
||||
<h1>Accounts</h1>
|
||||
<div>
|
||||
Pending <a href="https://github.com/superseriousbusiness/gotosocial/issues/581">#581</a>,
|
||||
there is currently no way to list accounts.<br />
|
||||
You can perform actions on reported accounts by clicking their name in the report, or searching for a username below.
|
||||
</div>
|
||||
|
||||
<AccountSearchForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSearchForm() {
|
||||
const [searchAccount, result] = query.useSearchAccountMutation();
|
||||
|
||||
const [onAccountChange, _resetAccount, { account }] = useTextInput("account");
|
||||
|
||||
function submitSearch(e) {
|
||||
e.preventDefault();
|
||||
if (account.trim().length != 0) {
|
||||
searchAccount(account);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-search">
|
||||
<form onSubmit={submitSearch}>
|
||||
<div className="form-field text">
|
||||
<label htmlFor="url">
|
||||
Account:
|
||||
</label>
|
||||
<div className="row">
|
||||
<input
|
||||
type="text"
|
||||
id="account"
|
||||
name="account"
|
||||
onChange={onAccountChange}
|
||||
value={account}
|
||||
/>
|
||||
<button disabled={result.isLoading}>
|
||||
<i className={[
|
||||
"fa fa-fw",
|
||||
(result.isLoading
|
||||
? "fa-refresh fa-spin"
|
||||
: "fa-search")
|
||||
].join(" ")} aria-hidden="true" title="Search" />
|
||||
<span className="sr-only">Search</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<AccountList
|
||||
isSuccess={result.isSuccess}
|
||||
data={result.data}
|
||||
isError={result.isError}
|
||||
error={result.error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountList({ isSuccess, data, isError, error }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
if (!(isSuccess || isError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Error error={error} />;
|
||||
}
|
||||
|
||||
if (data.length == 0) {
|
||||
return <b>No accounts found that match your query</b>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Results:</h2>
|
||||
<div className="list">
|
||||
{data.map((acc) => (
|
||||
<Link key={acc.acct} className="account entry" to={`${baseUrl}/${acc.id}`}>
|
||||
{acc.display_name?.length > 0
|
||||
? acc.display_name
|
||||
: acc.username
|
||||
}
|
||||
<span id="username">(@{acc.acct})</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
49
web/source/settings/admin/accounts/index.tsx
Normal file
49
web/source/settings/admin/accounts/index.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
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 { Switch, Route } from "wouter";
|
||||
|
||||
import AccountDetail from "./detail";
|
||||
import { AccountSearchForm } from "./search";
|
||||
|
||||
export default function Accounts({ baseUrl }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/:accountId`}>
|
||||
<AccountDetail />
|
||||
</Route>
|
||||
<AccountOverview />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountOverview({ }) {
|
||||
return (
|
||||
<div className="accounts-view">
|
||||
<h1>Accounts Overview</h1>
|
||||
<span>
|
||||
You can perform actions on an account by clicking
|
||||
its name in a report, or by searching for the account
|
||||
using the form below and clicking on its name.
|
||||
</span>
|
||||
<AccountSearchForm />
|
||||
</div>
|
||||
);
|
||||
}
|
40
web/source/settings/admin/accounts/pending/index.tsx
Normal file
40
web/source/settings/admin/accounts/pending/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
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 { useSearchAccountsQuery } from "../../../lib/query";
|
||||
import { AccountList } from "../../../components/account-list";
|
||||
|
||||
export default function AccountsPending() {
|
||||
const searchRes = useSearchAccountsQuery({status: "pending"});
|
||||
|
||||
return (
|
||||
<div className="accounts-view">
|
||||
<h1>Pending Accounts</h1>
|
||||
<AccountList
|
||||
isLoading={searchRes.isLoading}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
data={searchRes.data}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage="No pending account sign-ups."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
125
web/source/settings/admin/accounts/search/index.tsx
Normal file
125
web/source/settings/admin/accounts/search/index.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
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 { useLazySearchAccountsQuery } from "../../../lib/query";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
|
||||
import { AccountList } from "../../../components/account-list";
|
||||
import { SearchAccountParams } from "../../../lib/types/account";
|
||||
import { Select, TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
export function AccountSearchForm() {
|
||||
const [searchAcct, searchRes] = useLazySearchAccountsQuery();
|
||||
|
||||
const form = {
|
||||
origin: useTextInput("origin"),
|
||||
status: useTextInput("status"),
|
||||
permissions: useTextInput("permissions"),
|
||||
username: useTextInput("username"),
|
||||
display_name: useTextInput("display_name"),
|
||||
by_domain: useTextInput("by_domain"),
|
||||
email: useTextInput("email"),
|
||||
ip: useTextInput("ip"),
|
||||
};
|
||||
|
||||
function submitSearch(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Parse query parameters.
|
||||
const entries = Object.entries(form).map(([k, v]) => {
|
||||
// Take only defined form fields.
|
||||
if (v.value === undefined || v.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return [[k, v.value]];
|
||||
}).flatMap(kv => {
|
||||
// Remove any nulls.
|
||||
return kv || [];
|
||||
});
|
||||
|
||||
const params: SearchAccountParams = Object.fromEntries(entries);
|
||||
searchAcct(params);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submitSearch}>
|
||||
<TextInput
|
||||
field={form.username}
|
||||
label={"(Optional) username (without leading '@' symbol)"}
|
||||
placeholder="someone"
|
||||
/>
|
||||
<TextInput
|
||||
field={form.by_domain}
|
||||
label={"(Optional) domain"}
|
||||
placeholder="example.org"
|
||||
/>
|
||||
<Select
|
||||
field={form.origin}
|
||||
label="Account origin"
|
||||
options={
|
||||
<>
|
||||
<option value="">Local or remote</option>
|
||||
<option value="local">Local only</option>
|
||||
<option value="remote">Remote only</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<TextInput
|
||||
field={form.email}
|
||||
label={"(Optional) email address (local accounts only)"}
|
||||
placeholder={"someone@example.org"}
|
||||
/>
|
||||
<TextInput
|
||||
field={form.ip}
|
||||
label={"(Optional) IP address (local accounts only)"}
|
||||
placeholder={"198.51.100.0"}
|
||||
/>
|
||||
<Select
|
||||
field={form.status}
|
||||
label="Account status"
|
||||
options={
|
||||
<>
|
||||
<option value="">Any</option>
|
||||
<option value="pending">Pending only</option>
|
||||
<option value="disabled">Disabled only</option>
|
||||
<option value="suspended">Suspended only</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label={"Search"}
|
||||
result={searchRes}
|
||||
/>
|
||||
</form>
|
||||
<AccountList
|
||||
isLoading={searchRes.isLoading}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
data={searchRes.data}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage="No accounts found that match your query"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -17,19 +17,19 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React from "react";
|
||||
|
||||
const query = require("../../../lib/query");
|
||||
import { useInstanceKeysExpireMutation } from "../../../lib/query";
|
||||
|
||||
const { useTextInput } = require("../../../lib/form");
|
||||
const { TextInput } = require("../../../components/form/inputs");
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
module.exports = function ExpireRemote({}) {
|
||||
export default function ExpireRemote({}) {
|
||||
const domainField = useTextInput("domain");
|
||||
|
||||
const [expire, expireResult] = query.useInstanceKeysExpireMutation();
|
||||
const [expire, expireResult] = useInstanceKeysExpireMutation();
|
||||
|
||||
function submitExpire(e) {
|
||||
e.preventDefault();
|
||||
|
@ -53,7 +53,11 @@ module.exports = function ExpireRemote({}) {
|
|||
type="string"
|
||||
placeholder="example.org"
|
||||
/>
|
||||
<MutationButton label="Expire keys" result={expireResult} />
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Expire keys"
|
||||
result={expireResult}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -17,14 +17,14 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const ExpireRemote = require("./expireremote");
|
||||
import React from "react";
|
||||
import ExpireRemote from "./expireremote";
|
||||
|
||||
module.exports = function Keys() {
|
||||
export default function Keys() {
|
||||
return (
|
||||
<>
|
||||
<h1>Key Actions</h1>
|
||||
<ExpireRemote />
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -17,19 +17,19 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React from "react";
|
||||
|
||||
const query = require("../../../lib/query");
|
||||
import { useMediaCleanupMutation } from "../../../lib/query";
|
||||
|
||||
const { useTextInput } = require("../../../lib/form");
|
||||
const { TextInput } = require("../../../components/form/inputs");
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
module.exports = function Cleanup({}) {
|
||||
const daysField = useTextInput("days", { defaultValue: 30 });
|
||||
export default function Cleanup({}) {
|
||||
const daysField = useTextInput("days", { defaultValue: "30" });
|
||||
|
||||
const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation();
|
||||
const [mediaCleanup, mediaCleanupResult] = useMediaCleanupMutation();
|
||||
|
||||
function submitCleanup(e) {
|
||||
e.preventDefault();
|
||||
|
@ -51,7 +51,11 @@ module.exports = function Cleanup({}) {
|
|||
min="0"
|
||||
placeholder="30"
|
||||
/>
|
||||
<MutationButton label="Remove old media" result={mediaCleanupResult} />
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Remove old media"
|
||||
result={mediaCleanupResult}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -17,14 +17,14 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const Cleanup = require("./cleanup");
|
||||
import React from "react";
|
||||
import Cleanup from "./cleanup";
|
||||
|
||||
module.exports = function Media() {
|
||||
export default function Media() {
|
||||
return (
|
||||
<>
|
||||
<h1>Media Actions</h1>
|
||||
<Cleanup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -100,9 +100,9 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp
|
|||
onClick={() => submitParse()}
|
||||
result={parseResult}
|
||||
showError={false}
|
||||
disabled={false}
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
<label className="button with-icon">
|
||||
<label className={`button with-icon${form.permType.value === undefined || form.permType.value.length === 0 ? " disabled" : ""}`}>
|
||||
<i className="fa fa-fw " aria-hidden="true" />
|
||||
Import file
|
||||
<input
|
||||
|
@ -110,6 +110,7 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp
|
|||
className="hidden"
|
||||
onChange={fileChanged}
|
||||
accept="application/json,text/plain,text/csv"
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
</label>
|
||||
<b /> {/* grid filler */}
|
||||
|
@ -118,7 +119,7 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp
|
|||
type="button"
|
||||
onClick={() => submitExport("export")}
|
||||
result={exportResult} showError={false}
|
||||
disabled={false}
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Export to file"
|
||||
|
@ -127,7 +128,7 @@ export default function ImportExportForm({ form, submitParse, parseResult }: Imp
|
|||
onClick={() => submitExport("export-file")}
|
||||
result={exportResult}
|
||||
showError={false}
|
||||
disabled={false}
|
||||
disabled={form.permType.value === undefined || form.permType.value.length === 0}
|
||||
/>
|
||||
<div className="export-file">
|
||||
<span>
|
||||
|
|
|
@ -17,29 +17,25 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { useRoute, Link, Redirect } = require("wouter");
|
||||
import React, { useEffect } from "react";
|
||||
import { useRoute, Link, Redirect } from "wouter";
|
||||
|
||||
const { useComboBoxInput, useFileInput, useValue } = require("../../../lib/form");
|
||||
const { CategorySelect } = require("../category-select");
|
||||
import { useComboBoxInput, useFileInput, useValue } from "../../../lib/form";
|
||||
import { CategorySelect } from "../category-select";
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit").default;
|
||||
const { useBaseUrl } = require("../../../lib/navigation/util");
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
|
||||
const FakeToot = require("../../../components/fake-toot");
|
||||
const FormWithData = require("../../../lib/form/form-with-data").default;
|
||||
const Loading = require("../../../components/loading");
|
||||
const { FileInput } = require("../../../components/form/inputs");
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
const { Error } = require("../../../components/error");
|
||||
import FakeToot from "../../../components/fake-toot";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import Loading from "../../../components/loading";
|
||||
import { FileInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { Error } from "../../../components/error";
|
||||
|
||||
const {
|
||||
useGetEmojiQuery,
|
||||
useEditEmojiMutation,
|
||||
useDeleteEmojiMutation,
|
||||
} = require("../../../lib/query/admin/custom-emoji");
|
||||
import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../lib/query/admin/custom-emoji";
|
||||
|
||||
module.exports = function EmojiDetailRoute({ }) {
|
||||
export default function EmojiDetailRoute({ }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
let [_match, params] = useRoute(`${baseUrl}/:emojiId`);
|
||||
if (params?.emojiId == undefined) {
|
||||
|
@ -52,7 +48,7 @@ module.exports = function EmojiDetailRoute({ }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function EmojiDetailForm({ data: emoji }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
|
@ -68,7 +64,7 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
const [modifyEmoji, result] = useFormSubmit(form, useEditEmojiMutation());
|
||||
|
||||
// Automatic submitting of category change
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (
|
||||
form.category.hasChanged() &&
|
||||
!form.category.state.open &&
|
||||
|
|
|
@ -17,13 +17,13 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Switch, Route } = require("wouter");
|
||||
import React from "react";
|
||||
import { Switch, Route } from "wouter";
|
||||
|
||||
const EmojiOverview = require("./overview");
|
||||
const EmojiDetail = require("./detail");
|
||||
import EmojiOverview from "./overview";
|
||||
import EmojiDetail from "./detail";
|
||||
|
||||
module.exports = function CustomEmoji({ baseUrl }) {
|
||||
export default function CustomEmoji({ baseUrl }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${baseUrl}/:emojiId`}>
|
||||
|
@ -32,4 +32,4 @@ module.exports = function CustomEmoji({ baseUrl }) {
|
|||
<EmojiOverview />
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -17,31 +17,26 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
|
||||
const {
|
||||
useFileInput,
|
||||
useComboBoxInput
|
||||
} = require("../../../lib/form");
|
||||
const useShortcode = require("./use-shortcode");
|
||||
import { useFileInput, useComboBoxInput } from "../../../lib/form";
|
||||
import useShortcode from "./use-shortcode";
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit").default;
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
const {
|
||||
TextInput, FileInput
|
||||
} = require("../../../components/form/inputs");
|
||||
import { TextInput, FileInput } from "../../../components/form/inputs";
|
||||
|
||||
const { CategorySelect } = require('../category-select');
|
||||
const FakeToot = require("../../../components/fake-toot");
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
const { useAddEmojiMutation } = require("../../../lib/query/admin/custom-emoji");
|
||||
const { useInstanceV1Query } = require("../../../lib/query");
|
||||
import { CategorySelect } from '../category-select';
|
||||
import FakeToot from "../../../components/fake-toot";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../lib/query";
|
||||
|
||||
module.exports = function NewEmojiForm() {
|
||||
export default function NewEmojiForm() {
|
||||
const shortcode = useShortcode();
|
||||
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
const emojiMaxSize = React.useMemo(() => {
|
||||
const emojiMaxSize = useMemo(() => {
|
||||
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
|
||||
}, [instance]);
|
||||
|
||||
|
@ -56,8 +51,8 @@ module.exports = function NewEmojiForm() {
|
|||
shortcode, image, category
|
||||
}, useAddEmojiMutation());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shortcode.value.length == 0) {
|
||||
useEffect(() => {
|
||||
if (shortcode.value === undefined || shortcode.value.length == 0) {
|
||||
if (image.value != undefined) {
|
||||
let [name, _ext] = image.value.name.split(".");
|
||||
shortcode.setter(name);
|
||||
|
@ -71,7 +66,7 @@ module.exports = function NewEmojiForm() {
|
|||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [image.value]);
|
||||
|
||||
let emojiOrShortcode = `:${shortcode.value}:`;
|
||||
let emojiOrShortcode;
|
||||
|
||||
if (image.previewValue != undefined) {
|
||||
emojiOrShortcode = <img
|
||||
|
@ -80,6 +75,10 @@ module.exports = function NewEmojiForm() {
|
|||
title={`:${shortcode.value}:`}
|
||||
alt={shortcode.value}
|
||||
/>;
|
||||
} else if (shortcode.value !== undefined && shortcode.value.length > 0) {
|
||||
emojiOrShortcode = `:${shortcode.value}:`;
|
||||
} else {
|
||||
emojiOrShortcode = `:your_emoji_here:`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -103,10 +102,15 @@ module.exports = function NewEmojiForm() {
|
|||
|
||||
<CategorySelect
|
||||
field={category}
|
||||
children={[]}
|
||||
/>
|
||||
|
||||
<MutationButton label="Upload emoji" result={result} />
|
||||
<MutationButton
|
||||
disabled={image.previewValue === undefined}
|
||||
label="Upload emoji"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -22,7 +22,7 @@ const { Link } = require("wouter");
|
|||
const syncpipe = require("syncpipe");
|
||||
const { matchSorter } = require("match-sorter");
|
||||
|
||||
const NewEmojiForm = require("./new-emoji");
|
||||
const NewEmojiForm = require("./new-emoji").default;
|
||||
const { useTextInput } = require("../../../lib/form");
|
||||
|
||||
const { useEmojiByCategory } = require("../category-select");
|
||||
|
|
|
@ -17,15 +17,15 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
const ParseFromToot = require("./parse-from-toot");
|
||||
import ParseFromToot from "./parse-from-toot";
|
||||
|
||||
const Loading = require("../../../components/loading");
|
||||
const { Error } = require("../../../components/error");
|
||||
const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji");
|
||||
import Loading from "../../../components/loading";
|
||||
import { Error } from "../../../components/error";
|
||||
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
|
||||
|
||||
module.exports = function RemoteEmoji() {
|
||||
export default function RemoteEmoji() {
|
||||
// local emoji are queried for shortcode collision detection
|
||||
const {
|
||||
data: emoji = [],
|
||||
|
@ -33,7 +33,7 @@ module.exports = function RemoteEmoji() {
|
|||
error
|
||||
} = useListEmojiQuery({ filter: "domain:local" });
|
||||
|
||||
const emojiCodes = React.useMemo(() => {
|
||||
const emojiCodes = useMemo(() => {
|
||||
return new Set(emoji.map((e) => e.shortcode));
|
||||
}, [emoji]);
|
||||
|
||||
|
@ -46,9 +46,9 @@ module.exports = function RemoteEmoji() {
|
|||
{isLoading
|
||||
? <Loading />
|
||||
: <>
|
||||
<ParseFromToot emoji={emoji} emojiCodes={emojiCodes} />
|
||||
<ParseFromToot emojiCodes={emojiCodes} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -17,36 +17,28 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
const {
|
||||
useTextInput,
|
||||
useComboBoxInput,
|
||||
useCheckListInput
|
||||
} = require("../../../lib/form");
|
||||
import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../lib/form";
|
||||
|
||||
const useFormSubmit = require("../../../lib/form/submit").default;
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
|
||||
const CheckList = require("../../../components/check-list").default;
|
||||
const { CategorySelect } = require('../category-select');
|
||||
import CheckList from "../../../components/check-list";
|
||||
import { CategorySelect } from '../category-select';
|
||||
|
||||
const { TextInput } = require("../../../components/form/inputs");
|
||||
const MutationButton = require("../../../components/form/mutation-button");
|
||||
const { Error } = require("../../../components/error");
|
||||
const {
|
||||
useSearchItemForEmojiMutation,
|
||||
usePatchRemoteEmojisMutation
|
||||
} = require("../../../lib/query/admin/custom-emoji");
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { Error } from "../../../components/error";
|
||||
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../lib/query/admin/custom-emoji";
|
||||
|
||||
module.exports = function ParseFromToot({ emojiCodes }) {
|
||||
export default function ParseFromToot({ emojiCodes }) {
|
||||
const [searchStatus, result] = useSearchItemForEmojiMutation();
|
||||
|
||||
const [onURLChange, _resetURL, { url }] = useTextInput("url");
|
||||
const urlField = useTextInput("url");
|
||||
|
||||
function submitSearch(e) {
|
||||
e.preventDefault();
|
||||
if (url.trim().length != 0) {
|
||||
searchStatus(url);
|
||||
if (urlField.value !== undefined && urlField.value.trim().length != 0) {
|
||||
searchStatus(urlField.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,8 +55,8 @@ module.exports = function ParseFromToot({ emojiCodes }) {
|
|||
type="text"
|
||||
id="url"
|
||||
name="url"
|
||||
onChange={onURLChange}
|
||||
value={url}
|
||||
onChange={urlField.onChange}
|
||||
value={urlField.value}
|
||||
/>
|
||||
<button disabled={result.isLoading}>
|
||||
<i className={[
|
||||
|
@ -81,7 +73,7 @@ module.exports = function ParseFromToot({ emojiCodes }) {
|
|||
<SearchResult result={result} localEmojiCodes={emojiCodes} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function SearchResult({ result, localEmojiCodes }) {
|
||||
const { error, data, isSuccess, isError } = result;
|
||||
|
@ -106,7 +98,6 @@ function SearchResult({ result, localEmojiCodes }) {
|
|||
<CopyEmojiForm
|
||||
localEmojiCodes={localEmojiCodes}
|
||||
type={data.type}
|
||||
domain={data.domain}
|
||||
emojiList={data.list}
|
||||
/>
|
||||
);
|
||||
|
@ -139,13 +130,16 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
|
|||
);
|
||||
|
||||
const buttonsInactive = form.selectedEmoji.someSelected
|
||||
? {}
|
||||
? {
|
||||
disabled: false,
|
||||
title: ""
|
||||
}
|
||||
: {
|
||||
disabled: true,
|
||||
title: "No emoji selected, cannot perform any actions"
|
||||
};
|
||||
|
||||
const checkListExtraProps = React.useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
|
||||
const checkListExtraProps = useCallback(() => ({ localEmojiCodes }), [localEmojiCodes]);
|
||||
|
||||
return (
|
||||
<div className="parsed">
|
||||
|
@ -153,17 +147,32 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
|
|||
<form onSubmit={formSubmit}>
|
||||
<CheckList
|
||||
field={form.selectedEmoji}
|
||||
header={<></>}
|
||||
EntryComponent={EmojiEntry}
|
||||
getExtraProps={checkListExtraProps}
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
field={form.category}
|
||||
children={[]}
|
||||
/>
|
||||
|
||||
<div className="action-buttons row">
|
||||
<MutationButton name="copy" label="Copy to local emoji" result={result} showError={false} {...buttonsInactive} />
|
||||
<MutationButton name="disable" label="Disable" result={result} className="button danger" showError={false} {...buttonsInactive} />
|
||||
<MutationButton
|
||||
name="copy"
|
||||
label="Copy to local emoji"
|
||||
result={result}
|
||||
showError={false}
|
||||
{...buttonsInactive}
|
||||
/>
|
||||
<MutationButton
|
||||
name="disable"
|
||||
label="Disable"
|
||||
result={result}
|
||||
className="button danger"
|
||||
showError={false}
|
||||
{...buttonsInactive}
|
||||
/>
|
||||
</div>
|
||||
{result.error && (
|
||||
Array.isArray(result.error)
|
||||
|
@ -198,13 +207,13 @@ function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } })
|
|||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (emoji.valid != shortcodeField.valid) {
|
||||
onChange({ valid: shortcodeField.valid });
|
||||
}
|
||||
}, [onChange, emoji.valid, shortcodeField.valid]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
shortcodeField.validate();
|
||||
// only need this update if it's the emoji.checked that updated, not shortcodeField
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@ -17,26 +17,23 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { useRoute, Redirect } = require("wouter");
|
||||
import React, { useState } from "react";
|
||||
import { useRoute, Redirect } from "wouter";
|
||||
|
||||
const FormWithData = require("../../lib/form/form-with-data").default;
|
||||
const BackButton = require("../../components/back-button");
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import BackButton from "../../components/back-button";
|
||||
|
||||
const { useValue, useTextInput } = require("../../lib/form");
|
||||
const useFormSubmit = require("../../lib/form/submit").default;
|
||||
import { useValue, useTextInput } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
|
||||
const { TextArea } = require("../../components/form/inputs");
|
||||
import { TextArea } from "../../components/form/inputs";
|
||||
|
||||
const MutationButton = require("../../components/form/mutation-button");
|
||||
const Username = require("./username");
|
||||
const { useBaseUrl } = require("../../lib/navigation/util");
|
||||
const {
|
||||
useGetReportQuery,
|
||||
useResolveReportMutation,
|
||||
} = require("../../lib/query/admin/reports");
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import Username from "./username";
|
||||
import { useBaseUrl } from "../../lib/navigation/util";
|
||||
import { useGetReportQuery, useResolveReportMutation } from "../../lib/query/admin/reports";
|
||||
|
||||
module.exports = function ReportDetail({ }) {
|
||||
export default function ReportDetail({ }) {
|
||||
const baseUrl = useBaseUrl();
|
||||
let [_match, params] = useRoute(`${baseUrl}/:reportId`);
|
||||
if (params?.reportId == undefined) {
|
||||
|
@ -55,7 +52,7 @@ module.exports = function ReportDetail({ }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function ReportDetailForm({ data: report }) {
|
||||
const from = report.account;
|
||||
|
@ -131,7 +128,11 @@ function ReportActionForm({ report }) {
|
|||
field={form.comment}
|
||||
label="Comment"
|
||||
/>
|
||||
<MutationButton label="Resolve" result={result} />
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Resolve"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
@ -170,10 +171,10 @@ function ReportedToot({ toot }) {
|
|||
}
|
||||
</section>
|
||||
<aside className="status-info">
|
||||
<dl class="status-stats">
|
||||
<div class="stats-grouping">
|
||||
<div class="stats-item published-at text-cutoff">
|
||||
<dt class="sr-only">Published</dt>
|
||||
<dl className="status-stats">
|
||||
<div className="stats-grouping">
|
||||
<div className="stats-item published-at text-cutoff">
|
||||
<dt className="sr-only">Published</dt>
|
||||
<dd>
|
||||
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
|
||||
</dd>
|
||||
|
@ -186,7 +187,7 @@ function ReportedToot({ toot }) {
|
|||
}
|
||||
|
||||
function TootCW({ note, content }) {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function toggleVisible() {
|
||||
setVisible(!visible);
|
||||
|
@ -217,12 +218,12 @@ function TootMedia({ media, sensitive }) {
|
|||
<input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
|
||||
<div className="sensitive">
|
||||
<div className="open">
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex="0">
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
||||
<i className="fa fa-eye-slash" title="Hide sensitive media"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div className="closed" title={m.description}>
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex="0">
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
||||
Show sensitive media
|
||||
</label>
|
||||
</div>
|
||||
|
@ -241,8 +242,7 @@ function TootMedia({ media, sensitive }) {
|
|||
alt={m.description}
|
||||
src={m.url}
|
||||
// thumb={m.preview_url}
|
||||
size={m.meta?.original}
|
||||
type={m.type}
|
||||
sizes={m.meta?.original}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
|
@ -17,17 +17,17 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Link, Switch, Route } = require("wouter");
|
||||
import React from "react";
|
||||
import { Link, Switch, Route } from "wouter";
|
||||
|
||||
const FormWithData = require("../../lib/form/form-with-data").default;
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
|
||||
const ReportDetail = require("./detail");
|
||||
const Username = require("./username");
|
||||
const { useBaseUrl } = require("../../lib/navigation/util");
|
||||
const { useListReportsQuery } = require("../../lib/query/admin/reports");
|
||||
import ReportDetail from "./detail";
|
||||
import Username from "./username";
|
||||
import { useBaseUrl } from "../../lib/navigation/util";
|
||||
import { useListReportsQuery } from "../../lib/query/admin/reports";
|
||||
|
||||
module.exports = function Reports({ baseUrl }) {
|
||||
export default function Reports({ baseUrl }) {
|
||||
return (
|
||||
<div className="reports">
|
||||
<Switch>
|
||||
|
@ -38,7 +38,7 @@ module.exports = function Reports({ baseUrl }) {
|
|||
</Switch>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function ReportOverview({ }) {
|
||||
return (
|
|
@ -17,10 +17,10 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Link } = require("wouter");
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
module.exports = function Username({ user, link = true }) {
|
||||
export default function Username({ user, link = true }) {
|
||||
let className = "user";
|
||||
let isLocal = user.domain == null;
|
||||
|
||||
|
@ -36,8 +36,8 @@ module.exports = function Username({ user, link = true }) {
|
|||
? { fa: "fa-home", info: "Local user" }
|
||||
: { fa: "fa-external-link-square", info: "Remote user" };
|
||||
|
||||
let Element = "div";
|
||||
let href = null;
|
||||
let Element: any = "div";
|
||||
let href: any = null;
|
||||
|
||||
if (link) {
|
||||
Element = Link;
|
||||
|
@ -51,4 +51,4 @@ module.exports = function Username({ user, link = true }) {
|
|||
<span className="sr-only">{icon.info}</span>
|
||||
</Element>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -17,28 +17,29 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Switch, Route, Link, Redirect, useRoute } = require("wouter");
|
||||
import React from "react";
|
||||
import { Switch, Route, Link, Redirect, useRoute } from "wouter";
|
||||
|
||||
const query = require("../../lib/query");
|
||||
const FormWithData = require("../../lib/form/form-with-data").default;
|
||||
const { useBaseUrl } = require("../../lib/navigation/util");
|
||||
import { useInstanceRulesQuery, useAddInstanceRuleMutation, useUpdateInstanceRuleMutation, useDeleteInstanceRuleMutation } from "../../lib/query";
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import { useBaseUrl } from "../../lib/navigation/util";
|
||||
|
||||
const { useValue, useTextInput } = require("../../lib/form");
|
||||
const useFormSubmit = require("../../lib/form/submit").default;
|
||||
import { useValue, useTextInput } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
|
||||
const { TextArea } = require("../../components/form/inputs");
|
||||
const MutationButton = require("../../components/form/mutation-button");
|
||||
import { TextArea } from "../../components/form/inputs";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import { Error } from "../../components/error";
|
||||
|
||||
module.exports = function InstanceRulesData({ baseUrl }) {
|
||||
export default function InstanceRulesData({ baseUrl }) {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={query.useInstanceRulesQuery}
|
||||
dataQuery={useInstanceRulesQuery}
|
||||
DataForm={InstanceRules}
|
||||
baseUrl={baseUrl}
|
||||
{...{baseUrl}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function InstanceRules({ baseUrl, data: rules }) {
|
||||
return (
|
||||
|
@ -64,7 +65,8 @@ function InstanceRules({ baseUrl, data: rules }) {
|
|||
function InstanceRuleList({ rules }) {
|
||||
const newRule = useTextInput("text", {});
|
||||
|
||||
const [submitForm, result] = useFormSubmit({ newRule }, query.useAddInstanceRuleMutation(), {
|
||||
const [submitForm, result] = useFormSubmit({ newRule }, useAddInstanceRuleMutation(), {
|
||||
changedOnly: true,
|
||||
onFinish: () => newRule.reset()
|
||||
});
|
||||
|
||||
|
@ -72,7 +74,7 @@ function InstanceRuleList({ rules }) {
|
|||
<>
|
||||
<form onSubmit={submitForm} className="new-rule">
|
||||
<ol className="instance-rules">
|
||||
{Object.values(rules).map((rule) => (
|
||||
{Object.values(rules).map((rule: any) => (
|
||||
<InstanceRule key={rule.id} rule={rule} />
|
||||
))}
|
||||
</ol>
|
||||
|
@ -80,7 +82,11 @@ function InstanceRuleList({ rules }) {
|
|||
field={newRule}
|
||||
label="New instance rule"
|
||||
/>
|
||||
<MutationButton label="Add rule" result={result} />
|
||||
<MutationButton
|
||||
disabled={newRule.value === undefined || newRule.value.length === 0}
|
||||
label="Add rule"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
@ -124,9 +130,9 @@ function InstanceRuleForm({ rule }) {
|
|||
rule: useTextInput("text", { defaultValue: rule.text })
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceRuleMutation());
|
||||
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceRuleMutation());
|
||||
|
||||
const [deleteRule, deleteResult] = query.useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
|
||||
const [deleteRule, deleteResult] = useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
|
||||
|
||||
if (result.isSuccess || deleteResult.isSuccess) {
|
||||
return (
|
||||
|
@ -150,6 +156,7 @@ function InstanceRuleForm({ rule }) {
|
|||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
type="button"
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
label="Delete"
|
82
web/source/settings/components/account-list.tsx
Normal file
82
web/source/settings/components/account-list.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
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={`/settings/admin/accounts/${acc.id}`}
|
||||
>
|
||||
{acc.display_name?.length > 0
|
||||
? acc.display_name
|
||||
: acc.username
|
||||
}
|
||||
<span id="username">(@{acc.acct})</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,48 +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/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Error } = require("../error");
|
||||
|
||||
module.exports = function MutationButton({ label, result, disabled, showError = true, className = "", wrapperClassName = "", ...inputProps }) {
|
||||
let iconClass = "";
|
||||
const targetsThisButton = result.action == inputProps.name; // can also both be undefined, which is correct
|
||||
|
||||
if (targetsThisButton) {
|
||||
if (result.isLoading) {
|
||||
iconClass = "fa-spin fa-refresh";
|
||||
} else if (result.isSuccess) {
|
||||
iconClass = "fa-check fadeout";
|
||||
}
|
||||
}
|
||||
|
||||
return (<div className={wrapperClassName}>
|
||||
{(showError && targetsThisButton && result.error) &&
|
||||
<Error error={result.error} />
|
||||
}
|
||||
<button type="submit" className={"with-icon " + className} disabled={result.isLoading || disabled} {...inputProps}>
|
||||
<i className={`fa fa-fw ${iconClass}`} aria-hidden="true"></i>
|
||||
{(targetsThisButton && result.isLoading)
|
||||
? "Processing..."
|
||||
: label
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
72
web/source/settings/components/form/mutation-button.tsx
Normal file
72
web/source/settings/components/form/mutation-button.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
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 { Error } from "../error";
|
||||
|
||||
export interface MutationButtonProps extends React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
|
||||
label: string,
|
||||
result,
|
||||
disabled: boolean,
|
||||
showError?: boolean,
|
||||
className?: string,
|
||||
wrapperClassName?: string,
|
||||
}
|
||||
|
||||
export default function MutationButton({
|
||||
label,
|
||||
result,
|
||||
disabled,
|
||||
showError = true,
|
||||
className = "",
|
||||
wrapperClassName = "",
|
||||
...inputProps
|
||||
}: MutationButtonProps) {
|
||||
let iconClass = "";
|
||||
// Can also both be undefined, which is correct.
|
||||
const targetsThisButton = result.action == inputProps.name;
|
||||
|
||||
if (targetsThisButton) {
|
||||
if (result.isLoading) {
|
||||
iconClass = " fa-spin fa-refresh";
|
||||
} else if (result.isSuccess) {
|
||||
iconClass = " fa-check fadeout";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
{(showError && targetsThisButton && result.error) &&
|
||||
<Error error={result.error} />
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
className={"with-icon " + className}
|
||||
disabled={result.isLoading || disabled}
|
||||
{...inputProps}
|
||||
>
|
||||
<i className={`fa fa-fw${iconClass}`} aria-hidden="true"></i>
|
||||
{(targetsThisButton && result.isLoading)
|
||||
? "Processing..."
|
||||
: label
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -34,10 +34,22 @@ const UserProfile = require("./user/profile").default;
|
|||
const UserSettings = require("./user/settings").default;
|
||||
const UserMigration = require("./user/migration").default;
|
||||
|
||||
const Reports = require("./admin/reports").default;
|
||||
|
||||
const Accounts = require("./admin/accounts").default;
|
||||
const AccountsPending = require("./admin/accounts/pending").default;
|
||||
|
||||
const DomainPerms = require("./admin/domain-permissions").default;
|
||||
const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default;
|
||||
|
||||
const AdminMedia = require("./admin/actions/media").default;
|
||||
const AdminKeys = require("./admin/actions/keys").default;
|
||||
|
||||
const LocalEmoji = require("./admin/emoji/local").default;
|
||||
const RemoteEmoji = require("./admin/emoji/remote").default;
|
||||
|
||||
const InstanceSettings = require("./admin/settings").default;
|
||||
const InstanceRules = require("./admin/settings/rules").default;
|
||||
|
||||
require("./style.css");
|
||||
|
||||
|
@ -51,8 +63,11 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
|
|||
url: "admin",
|
||||
permissions: ["admin"]
|
||||
}, [
|
||||
Item("Reports", { icon: "fa-flag", wildcard: true }, require("./admin/reports")),
|
||||
Item("Accounts", { icon: "fa-users", wildcard: true }, require("./admin/accounts")),
|
||||
Item("Reports", { icon: "fa-flag", wildcard: true }, Reports),
|
||||
Item("Accounts", { icon: "fa-users", wildcard: true }, [
|
||||
Item("Overview", { icon: "fa-list", url: "", wildcard: true }, Accounts),
|
||||
Item("Pending", { icon: "fa-question", url: "pending", wildcard: true }, AccountsPending),
|
||||
]),
|
||||
Menu("Domain Permissions", { icon: "fa-hubzilla" }, [
|
||||
Item("Blocks", { icon: "fa-close", url: "block", wildcard: true }, DomainPerms),
|
||||
Item("Allows", { icon: "fa-check", url: "allow", wildcard: true }, DomainPerms),
|
||||
|
@ -65,16 +80,16 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
|
|||
permissions: ["admin"]
|
||||
}, [
|
||||
Menu("Actions", { icon: "fa-bolt" }, [
|
||||
Item("Media", { icon: "fa-photo" }, require("./admin/actions/media")),
|
||||
Item("Keys", { icon: "fa-key-modern" }, require("./admin/actions/keys")),
|
||||
Item("Media", { icon: "fa-photo" }, AdminMedia),
|
||||
Item("Keys", { icon: "fa-key-modern" }, AdminKeys),
|
||||
]),
|
||||
Menu("Custom Emoji", { icon: "fa-smile-o" }, [
|
||||
Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")),
|
||||
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
|
||||
Item("Local", { icon: "fa-home", wildcard: true }, LocalEmoji),
|
||||
Item("Remote", { icon: "fa-cloud" }, RemoteEmoji),
|
||||
]),
|
||||
Menu("Settings", { icon: "fa-sliders" }, [
|
||||
Item("Settings", { icon: "fa-sliders", url: "" }, InstanceSettings),
|
||||
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
|
||||
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, InstanceRules),
|
||||
]),
|
||||
])
|
||||
]);
|
||||
|
|
|
@ -17,16 +17,16 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const RoleContext = React.createContext([]);
|
||||
const BaseUrlContext = React.createContext(null);
|
||||
import { createContext, useContext } from "react";
|
||||
const RoleContext = createContext([]);
|
||||
const BaseUrlContext = createContext<string>("");
|
||||
|
||||
function urlSafe(str) {
|
||||
return str.toLowerCase().replace(/[\s/]+/g, "-");
|
||||
}
|
||||
|
||||
function useHasPermission(permissions) {
|
||||
const roles = React.useContext(RoleContext);
|
||||
const roles = useContext(RoleContext);
|
||||
return checkPermission(permissions, roles);
|
||||
}
|
||||
|
||||
|
@ -41,9 +41,14 @@ function checkPermission(requiredPermissisons, user) {
|
|||
}
|
||||
|
||||
function useBaseUrl() {
|
||||
return React.useContext(BaseUrlContext);
|
||||
return useContext(BaseUrlContext);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
urlSafe, RoleContext, useHasPermission, checkPermission, BaseUrlContext, useBaseUrl
|
||||
export {
|
||||
urlSafe,
|
||||
RoleContext,
|
||||
useHasPermission,
|
||||
checkPermission,
|
||||
BaseUrlContext,
|
||||
useBaseUrl
|
||||
};
|
|
@ -20,6 +20,7 @@
|
|||
import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers";
|
||||
import { gtsApi } from "../gts-api";
|
||||
import { listToKeyedObject } from "../transforms";
|
||||
import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
|
@ -54,14 +55,43 @@ const extended = gtsApi.injectEndpoints({
|
|||
})
|
||||
}),
|
||||
|
||||
getAccount: build.query({
|
||||
getAccount: build.query<AdminAccount, string>({
|
||||
query: (id) => ({
|
||||
url: `/api/v1/accounts/${id}`
|
||||
url: `/api/v1/admin/accounts/${id}`
|
||||
}),
|
||||
providesTags: (_, __, id) => [{ type: "Account", id }]
|
||||
providesTags: (_result, _error, id) => [
|
||||
{ type: 'Account', id }
|
||||
],
|
||||
}),
|
||||
|
||||
actionAccount: build.mutation({
|
||||
searchAccounts: build.query<AdminAccount[], SearchAccountParams>({
|
||||
query: (form) => {
|
||||
const params = new(URLSearchParams);
|
||||
Object.entries(form).forEach(([k, v]) => {
|
||||
if (v !== undefined) {
|
||||
params.append(k, v);
|
||||
}
|
||||
});
|
||||
|
||||
let query = "";
|
||||
if (params.size !== 0) {
|
||||
query = `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return {
|
||||
url: `/api/v2/admin/accounts${query}`
|
||||
};
|
||||
},
|
||||
providesTags: (res) =>
|
||||
res
|
||||
? [
|
||||
...res.map(({ id }) => ({ type: 'Account' as const, id })),
|
||||
{ type: 'Account', id: 'LIST' },
|
||||
]
|
||||
: [{ type: 'Account', id: 'LIST' }],
|
||||
}),
|
||||
|
||||
actionAccount: build.mutation<string, { id: string, action: string, reason: string }>({
|
||||
query: ({ id, action, reason }) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/admin/accounts/${id}/action`,
|
||||
|
@ -71,16 +101,23 @@ const extended = gtsApi.injectEndpoints({
|
|||
text: reason
|
||||
}
|
||||
}),
|
||||
invalidatesTags: (_, __, { id }) => [{ type: "Account", id }]
|
||||
invalidatesTags: (_result, _error, { id }) => [
|
||||
{ type: 'Account', id },
|
||||
],
|
||||
}),
|
||||
|
||||
searchAccount: build.mutation({
|
||||
query: (username) => ({
|
||||
url: `/api/v2/search?q=${encodeURIComponent(username)}&resolve=true`
|
||||
}),
|
||||
transformResponse: (res) => {
|
||||
return res.accounts ?? [];
|
||||
}
|
||||
handleSignup: build.mutation<AdminAccount, HandleSignupParams>({
|
||||
query: ({id, approve_or_reject, ...formData}) => {
|
||||
return {
|
||||
method: "POST",
|
||||
url: `/api/v1/admin/accounts/${id}/${approve_or_reject}`,
|
||||
asForm: true,
|
||||
body: approve_or_reject === "reject" ?? formData,
|
||||
};
|
||||
},
|
||||
invalidatesTags: (_result, _error, { id }) => [
|
||||
{ type: 'Account', id },
|
||||
],
|
||||
}),
|
||||
|
||||
instanceRules: build.query({
|
||||
|
@ -140,7 +177,9 @@ export const {
|
|||
useInstanceKeysExpireMutation,
|
||||
useGetAccountQuery,
|
||||
useActionAccountMutation,
|
||||
useSearchAccountMutation,
|
||||
useSearchAccountsQuery,
|
||||
useLazySearchAccountsQuery,
|
||||
useHandleSignupMutation,
|
||||
useInstanceRulesQuery,
|
||||
useAddInstanceRuleMutation,
|
||||
useUpdateInstanceRuleMutation,
|
||||
|
|
|
@ -36,7 +36,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
...params
|
||||
}
|
||||
}),
|
||||
providesTags: ["Reports"]
|
||||
providesTags: [{ type: "Reports", id: "LIST" }]
|
||||
}),
|
||||
|
||||
getReport: build.query<AdminReport, string>({
|
||||
|
|
88
web/source/settings/lib/types/account.ts
Normal file
88
web/source/settings/lib/types/account.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
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 { CustomEmoji } from "./custom-emoji";
|
||||
|
||||
export interface AdminAccount {
|
||||
id: string,
|
||||
username: string,
|
||||
domain: string | null,
|
||||
created_at: string,
|
||||
email: string,
|
||||
ip: string | null,
|
||||
ips: [],
|
||||
locale: string,
|
||||
invite_request: string | null,
|
||||
role: any,
|
||||
confirmed: boolean,
|
||||
approved: boolean,
|
||||
disabled: boolean,
|
||||
silenced: boolean,
|
||||
suspended: boolean,
|
||||
created_by_application_id: string,
|
||||
account: Account,
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string,
|
||||
username: string,
|
||||
acct: string,
|
||||
display_name: string,
|
||||
locked: boolean,
|
||||
discoverable: boolean,
|
||||
bot: boolean,
|
||||
created_at: string,
|
||||
note: string,
|
||||
url: string,
|
||||
avatar: string,
|
||||
avatar_static: string,
|
||||
header: string,
|
||||
header_static: string,
|
||||
followers_count: number,
|
||||
following_count: number,
|
||||
statuses_count: number,
|
||||
last_status_at: string,
|
||||
emojis: CustomEmoji[],
|
||||
fields: [],
|
||||
enable_rss: boolean,
|
||||
role: any,
|
||||
}
|
||||
|
||||
export interface SearchAccountParams {
|
||||
origin?: "local" | "remote",
|
||||
status?: "active" | "pending" | "disabled" | "silenced" | "suspended",
|
||||
permissions?: "staff",
|
||||
username?: string,
|
||||
display_name?: string,
|
||||
by_domain?: string,
|
||||
email?: string,
|
||||
ip?: string,
|
||||
max_id?: string,
|
||||
since_id?: string,
|
||||
min_id?: string,
|
||||
limit?: number,
|
||||
}
|
||||
|
||||
export interface HandleSignupParams {
|
||||
id: string,
|
||||
approve_or_reject: "approve" | "reject",
|
||||
private_comment?: string,
|
||||
message?: string,
|
||||
send_email?: boolean,
|
||||
}
|
|
@ -804,17 +804,13 @@ span.form-info {
|
|||
.info {
|
||||
color: $info-fg;
|
||||
background: $info-bg;
|
||||
padding: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: $br;
|
||||
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-top: 0.1em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $info-link;
|
||||
}
|
||||
|
@ -1145,7 +1141,7 @@ button.with-padding {
|
|||
}
|
||||
}
|
||||
|
||||
.account-search {
|
||||
.accounts-view {
|
||||
form {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
@ -1175,9 +1171,42 @@ button.with-padding {
|
|||
max-width: 60rem;
|
||||
}
|
||||
|
||||
h4, h3, h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
border: 0.1rem solid $gray1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.info-list-entry {
|
||||
background: $list-entry-bg;
|
||||
border: 0.1rem solid transparent;
|
||||
padding: 0.25rem;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: $list-entry-alternate-bg;
|
||||
}
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: max(20%, 10rem) 1fr;
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dd {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,4 +27,6 @@ To confirm your email, paste the following in your browser's address bar:
|
|||
|
||||
{{ .ConfirmLink }}
|
||||
|
||||
---
|
||||
|
||||
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{ .InstanceURL -}}.
|
||||
|
|
|
@ -25,3 +25,7 @@ The report you submitted has now been closed.
|
|||
|
||||
{{ if .ActionTakenComment }}The moderator who closed the report left the following comment: {{ .ActionTakenComment }}
|
||||
{{- else }}The moderator who closed the report did not leave a comment.{{ end }}
|
||||
|
||||
---
|
||||
|
||||
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{ .InstanceURL -}}.
|
||||
|
|
|
@ -25,4 +25,6 @@ To reset your password, paste the following in your browser's address bar:
|
|||
|
||||
{{.ResetLink}}
|
||||
|
||||
---
|
||||
|
||||
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{.InstanceURL}}.
|
||||
|
|
34
web/template/email_signup_approved.tmpl
Normal file
34
web/template/email_signup_approved.tmpl
Normal file
|
@ -0,0 +1,34 @@
|
|||
{{- /*
|
||||
// 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/>.
|
||||
*/ -}}
|
||||
|
||||
Hello {{ .Username -}}!
|
||||
|
||||
You are receiving this mail because your request for an account on {{ .InstanceName }} has been approved by a moderator. Welcome!
|
||||
|
||||
If you have already confirmed your email address, you can now log in to your new account using a client application of your choice.
|
||||
|
||||
Some client applications known to work with GoToSocial are listed here: {{ .InstanceURL -}}#apps.
|
||||
|
||||
If you have not yet confirmed your email address, you will not be able to log in until you have done so.
|
||||
|
||||
Please check your inbox for the relevant email containing the confirmation link.
|
||||
|
||||
---
|
||||
|
||||
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{ .InstanceURL -}}.
|
28
web/template/email_signup_rejected.tmpl
Normal file
28
web/template/email_signup_rejected.tmpl
Normal file
|
@ -0,0 +1,28 @@
|
|||
{{- /*
|
||||
// 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/>.
|
||||
*/ -}}
|
||||
|
||||
Hello!
|
||||
|
||||
You are receiving this mail because your request for an account on {{ .InstanceName }} has been rejected by a moderator.
|
||||
|
||||
{{ if .Message }}The moderator who handled the sign-up included the following message regarding this rejection: "{{- .Message -}}"{{ end }}
|
||||
|
||||
---
|
||||
|
||||
If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of {{ .InstanceURL -}}.
|
Loading…
Reference in a new issue