mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-03-03 20:29:00 +01:00
[feature] Add token review / delete to backend + settings panel
This commit is contained in:
parent
1b37944f8b
commit
4638eda919
25 changed files with 1637 additions and 1 deletions
|
@ -3369,6 +3369,37 @@ definitions:
|
|||
type: object
|
||||
x-go-name: ThreadContext
|
||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
||||
tokenInfo:
|
||||
description: The actual access token itself will never be sent via the API.
|
||||
properties:
|
||||
application:
|
||||
$ref: '#/definitions/application'
|
||||
created_at:
|
||||
description: When the token was created (ISO 8601 Datetime).
|
||||
example: "2021-07-30T09:20:25+00:00"
|
||||
type: string
|
||||
x-go-name: CreatedAt
|
||||
id:
|
||||
description: Database ID of this token.
|
||||
example: 01JMW7QBAZYZ8T8H73PCEX12XG
|
||||
type: string
|
||||
x-go-name: ID
|
||||
last_used:
|
||||
description: |-
|
||||
Approximate time (accurate to within an hour) when the token was last used (ISO 8601 Datetime).
|
||||
Omitted if token has never been used, or it is not known when it was last used (eg., it was last used before tracking "last_used" became a thing).
|
||||
example: "2021-07-30T09:20:25+00:00"
|
||||
type: string
|
||||
x-go-name: LastUsed
|
||||
scope:
|
||||
description: OAuth scopes granted by the token, space-separated.
|
||||
example: read write admin
|
||||
type: string
|
||||
x-go-name: Scope
|
||||
title: TokenInfo represents metadata about one user-level access token.
|
||||
type: object
|
||||
x-go-name: TokenInfo
|
||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
||||
user:
|
||||
properties:
|
||||
admin:
|
||||
|
@ -11642,6 +11673,124 @@ paths:
|
|||
summary: See public statuses that use the given hashtag (case insensitive).
|
||||
tags:
|
||||
- timelines
|
||||
/api/v1/tokens:
|
||||
get:
|
||||
description: |-
|
||||
The items will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
|
||||
|
||||
The returned Link header can be used to generate the previous and next queries when paging up or down.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
<https://example.org/api/v1/tokens?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/tokens?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev"
|
||||
````
|
||||
operationId: tokensInfoGet
|
||||
parameters:
|
||||
- description: Return only items *OLDER* than the given max status ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: max_id
|
||||
type: string
|
||||
- description: Return only items *newer* than the given since status ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: since_id
|
||||
type: string
|
||||
- description: Return only items *immediately newer* than the given since status ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: min_id
|
||||
type: string
|
||||
- default: 20
|
||||
description: Number of items to return.
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Array of token info entries.
|
||||
headers:
|
||||
Link:
|
||||
description: Links to the next and previous queries.
|
||||
type: string
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/tokenInfo'
|
||||
type: array
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: See info about tokens created for/by your account.
|
||||
tags:
|
||||
- tokens
|
||||
/api/v1/tokens/{id}:
|
||||
get:
|
||||
operationId: tokenInfoGet
|
||||
parameters:
|
||||
- description: The id of the requested token.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The requested token.
|
||||
schema:
|
||||
$ref: '#/definitions/tokenInfo'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: Get information about a single token.
|
||||
tags:
|
||||
- tokens
|
||||
/api/v1/tokens/{id}/invalidate:
|
||||
post:
|
||||
operationId: tokenInvalidatePost
|
||||
parameters:
|
||||
- description: The id of the target token.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Info about the invalidated token.
|
||||
schema:
|
||||
$ref: '#/definitions/tokenInfo'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- write:accounts
|
||||
summary: Invalidate the target token, removing it from the database and making it unusable.
|
||||
tags:
|
||||
- tokens
|
||||
/api/v1/user:
|
||||
get:
|
||||
operationId: getUser
|
||||
|
|
BIN
docs/overrides/public/user-settings-access-tokens.png
Normal file
BIN
docs/overrides/public/user-settings-access-tokens.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 207 KiB |
|
@ -269,3 +269,21 @@ Both merge and overwrite operations are idempotent, which basically means that d
|
|||
|
||||
!!! warning
|
||||
The CSV format for mutes does not contain expiration data, so temporary mutes are exported (and imported) as permanent mutes.
|
||||
|
||||
## Access Tokens
|
||||
|
||||
In the access tokens section, you can review and invalidate [OAuth access tokens](https://www.oauth.com/oauth2-servers/access-tokens/) owned by applications that you have authorized to access your account and/or perform actions on your behalf.
|
||||
|
||||

|
||||
|
||||
In cases where you've logged in with an application multiple times, or logged in with multiple devices or browsers, you may see multiple tokens with the same application name. This is normal! For example, say you have logged in with Pinafore on both your phone and your laptop browser, you will see two different tokens owned by Pinafore.
|
||||
|
||||
You can invalidate a token by clicking on the "Invalidate token" button under a token. This will remove the token from the database. The application that was authorized to access your account with that token will then no longer be authorized to do so, and you will need to log out and/or log in again with that application.
|
||||
|
||||
Logging out of an application does not necessarily remove the token from the GoToSocial database, so old tokens may linger from applications you used a long time ago. So, feel free to invalidate tokens that have never been used, or haven't been used in a long time; it's good security practice to keep only the tokens that you need, and it's fun to click the big red button.
|
||||
|
||||
!!! danger
|
||||
If you see any tokens from applications that you do not recognize, or do not remember authorizing to access your account, then you should invalidate them, and consider changing your password as soon as possible.
|
||||
|
||||
!!! note
|
||||
Token "Last used" time is approximate and may be off by an hour in either direction.
|
||||
|
|
|
@ -54,6 +54,7 @@
|
|||
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||
|
@ -99,6 +100,7 @@ type Client struct {
|
|||
streaming *streaming.Module // api/v1/streaming
|
||||
tags *tags.Module // api/v1/tags
|
||||
timelines *timelines.Module // api/v1/timelines
|
||||
tokens *tokens.Module // api/v1/tokens
|
||||
user *user.Module // api/v1/user
|
||||
}
|
||||
|
||||
|
@ -152,6 +154,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
|
|||
c.streaming.Route(h)
|
||||
c.tags.Route(h)
|
||||
c.timelines.Route(h)
|
||||
c.tokens.Route(h)
|
||||
c.user.Route(h)
|
||||
}
|
||||
|
||||
|
@ -193,6 +196,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
|||
streaming: streaming.New(p, time.Second*30, 4096),
|
||||
tags: tags.New(p),
|
||||
timelines: timelines.New(p),
|
||||
tokens: tokens.New(p),
|
||||
user: user.New(p),
|
||||
}
|
||||
}
|
||||
|
|
98
internal/api/client/tokens/tokenget.go
Normal file
98
internal/api/client/tokens/tokenget.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
// 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 tokens
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
// TokenInfoGETHandler swagger:operation GET /api/v1/tokens/{id} tokenInfoGet
|
||||
//
|
||||
// Get information about a single token.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tokens
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// type: string
|
||||
// description: The id of the requested token.
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: The requested token.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/tokenInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) TokenInfoGETHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeReadAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
tokenID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
tokenInfo, errWithCode := m.processor.Account().TokenGet(
|
||||
c.Request.Context(),
|
||||
authed.User.ID,
|
||||
tokenID,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, tokenInfo)
|
||||
}
|
78
internal/api/client/tokens/tokenget_test.go
Normal file
78
internal/api/client/tokens/tokenget_test.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
// 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 tokens_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
|
||||
)
|
||||
|
||||
type TokenGetTestSuite struct {
|
||||
TokensStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *TokenGetTestSuite) TestTokenGet() {
|
||||
var (
|
||||
testToken = suite.testTokens["local_account_1"]
|
||||
testPath = "/api" + tokens.BasePath + "/" + testToken.ID
|
||||
)
|
||||
|
||||
out, code := suite.req(
|
||||
http.MethodGet,
|
||||
testPath,
|
||||
suite.tokens.TokenInfoGETHandler,
|
||||
map[string]string{"id": testToken.ID},
|
||||
)
|
||||
|
||||
suite.Equal(http.StatusOK, code)
|
||||
suite.Equal(`{
|
||||
"id": "01F8MGTQW4DKTDF8SW5CT9HYGA",
|
||||
"created_at": "2021-06-20T10:53:00.164Z",
|
||||
"scope": "read write push",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
}
|
||||
}`, out)
|
||||
}
|
||||
|
||||
func (suite *TokenGetTestSuite) TestTokenGetNotOurs() {
|
||||
var (
|
||||
testToken = suite.testTokens["admin_account"]
|
||||
testPath = "/api" + tokens.BasePath + "/" + testToken.ID
|
||||
)
|
||||
|
||||
out, code := suite.req(
|
||||
http.MethodGet,
|
||||
testPath,
|
||||
suite.tokens.TokenInfoGETHandler,
|
||||
map[string]string{"id": testToken.ID},
|
||||
)
|
||||
|
||||
suite.Equal(http.StatusNotFound, code)
|
||||
suite.Equal(`{
|
||||
"error": "Not Found"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
func TestTokenGetTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(TokenGetTestSuite))
|
||||
}
|
103
internal/api/client/tokens/tokeninvalidate.go
Normal file
103
internal/api/client/tokens/tokeninvalidate.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
// 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 tokens
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
// TokenInvalidatePOSTHandler swagger:operation POST /api/v1/tokens/{id}/invalidate tokenInvalidatePost
|
||||
//
|
||||
// Invalidate the target token, removing it from the database and making it unusable.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tokens
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// type: string
|
||||
// description: The id of the target token.
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: Info about the invalidated token.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/tokenInfo"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) TokenInvalidatePOSTHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeWriteAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, 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
|
||||
}
|
||||
|
||||
tokenID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
tokenInfo, errWithCode := m.processor.Account().TokenInvalidate(
|
||||
c.Request.Context(),
|
||||
authed.User.ID,
|
||||
tokenID,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, tokenInfo)
|
||||
}
|
87
internal/api/client/tokens/tokeninvalidate_test.go
Normal file
87
internal/api/client/tokens/tokeninvalidate_test.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
// 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 tokens_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
)
|
||||
|
||||
type TokenInvalidateTestSuite struct {
|
||||
TokensStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *TokenInvalidateTestSuite) TestTokenInvalidate() {
|
||||
var (
|
||||
testToken = suite.testTokens["local_account_1"]
|
||||
testPath = "/api" + tokens.BasePath + "/" + testToken.ID + "/invalidate"
|
||||
)
|
||||
|
||||
out, code := suite.req(
|
||||
http.MethodPost,
|
||||
testPath,
|
||||
suite.tokens.TokenInvalidatePOSTHandler,
|
||||
map[string]string{"id": testToken.ID},
|
||||
)
|
||||
|
||||
suite.Equal(http.StatusOK, code)
|
||||
suite.Equal(`{
|
||||
"id": "01F8MGTQW4DKTDF8SW5CT9HYGA",
|
||||
"created_at": "2021-06-20T10:53:00.164Z",
|
||||
"scope": "read write push",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
}
|
||||
}`, out)
|
||||
|
||||
// Check database for token we
|
||||
// just invalidated, should be gone.
|
||||
_, err := suite.testStructs.State.DB.GetTokenByID(
|
||||
context.Background(), testToken.ID,
|
||||
)
|
||||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
}
|
||||
|
||||
func (suite *TokenInvalidateTestSuite) TestTokenInvalidateNotOurs() {
|
||||
var (
|
||||
testToken = suite.testTokens["admin_account"]
|
||||
testPath = "/api" + tokens.BasePath + "/" + testToken.ID + "/invalidate"
|
||||
)
|
||||
|
||||
out, code := suite.req(
|
||||
http.MethodGet,
|
||||
testPath,
|
||||
suite.tokens.TokenInfoGETHandler,
|
||||
map[string]string{"id": testToken.ID},
|
||||
)
|
||||
|
||||
suite.Equal(http.StatusNotFound, code)
|
||||
suite.Equal(`{
|
||||
"error": "Not Found"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
func TestTokenInvalidateTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(TokenInvalidateTestSuite))
|
||||
}
|
48
internal/api/client/tokens/tokens.go
Normal file
48
internal/api/client/tokens/tokens.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
// 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 tokens
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
)
|
||||
|
||||
const (
|
||||
BasePath = "/v1/tokens"
|
||||
BasePathWithID = BasePath + "/:" + apiutil.IDKey
|
||||
InvalidateTokenPath = BasePathWithID + "/invalidate"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
processor *processing.Processor
|
||||
}
|
||||
|
||||
func New(processor *processing.Processor) *Module {
|
||||
return &Module{
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodGet, BasePath, m.TokensInfoGETHandler)
|
||||
attachHandler(http.MethodGet, BasePathWithID, m.TokensInfoGETHandler)
|
||||
attachHandler(http.MethodPost, InvalidateTokenPath, m.TokenInvalidatePOSTHandler)
|
||||
}
|
117
internal/api/client/tokens/tokens_test.go
Normal file
117
internal/api/client/tokens/tokens_test.go
Normal file
|
@ -0,0 +1,117 @@
|
|||
// 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 tokens_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type TokensStandardTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*gtsmodel.Token
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testStructs *testrig.TestStructs
|
||||
|
||||
// module being tested
|
||||
tokens *tokens.Module
|
||||
}
|
||||
|
||||
func (suite *TokensStandardTestSuite) req(
|
||||
httpMethod string,
|
||||
requestPath string,
|
||||
handler gin.HandlerFunc,
|
||||
pathParams map[string]string,
|
||||
) (string, int) {
|
||||
var (
|
||||
recorder = httptest.NewRecorder()
|
||||
ctx, _ = testrig.CreateGinTestContext(recorder, nil)
|
||||
)
|
||||
|
||||
// Prepare test context.
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
|
||||
// Prepare test context request.
|
||||
request := httptest.NewRequest(httpMethod, requestPath, nil)
|
||||
request.Header.Set("accept", "application/json")
|
||||
ctx.Request = request
|
||||
|
||||
// Inject path parameters.
|
||||
if pathParams != nil {
|
||||
for k, v := range pathParams {
|
||||
ctx.AddParam(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the handler
|
||||
handler(ctx)
|
||||
|
||||
// Read the response
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Format as nice indented json.
|
||||
dst := &bytes.Buffer{}
|
||||
if err := json.Indent(dst, b, "", " "); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
return dst.String(), recorder.Code
|
||||
}
|
||||
|
||||
func (suite *TokensStandardTestSuite) SetupSuite() {
|
||||
testrig.InitTestConfig()
|
||||
testrig.InitTestLog()
|
||||
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
}
|
||||
|
||||
func (suite *TokensStandardTestSuite) SetupTest() {
|
||||
suite.testStructs = testrig.SetupTestStructs(
|
||||
"../../../../testrig/media",
|
||||
"../../../../web/template",
|
||||
)
|
||||
suite.tokens = tokens.New(suite.testStructs.Processor)
|
||||
}
|
||||
|
||||
func (suite *TokensStandardTestSuite) TearDownTest() {
|
||||
testrig.TearDownTestStructs(suite.testStructs)
|
||||
}
|
144
internal/api/client/tokens/tokensget.go
Normal file
144
internal/api/client/tokens/tokensget.go
Normal file
|
@ -0,0 +1,144 @@
|
|||
// 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 tokens
|
||||
|
||||
import (
|
||||
"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/paging"
|
||||
)
|
||||
|
||||
// TokensInfoGETHandler swagger:operation GET /api/v1/tokens tokensInfoGet
|
||||
//
|
||||
// See info about tokens created for/by your account.
|
||||
//
|
||||
// The items will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
|
||||
//
|
||||
// The returned Link header can be used to generate the previous and next queries when paging up or down.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ```
|
||||
// <https://example.org/api/v1/tokens?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/tokens?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev"
|
||||
// ````
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - tokens
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: max_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only items *OLDER* than the given max status ID.
|
||||
// The item with the specified ID will not be included in the response.
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: since_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only items *newer* than the given since status ID.
|
||||
// The item with the specified ID will not be included in the response.
|
||||
// in: query
|
||||
// -
|
||||
// name: min_id
|
||||
// type: string
|
||||
// description: >-
|
||||
// Return only items *immediately newer* than the given since status ID.
|
||||
// The item with the specified ID will not be included in the response.
|
||||
// in: query
|
||||
// required: false
|
||||
// -
|
||||
// name: limit
|
||||
// type: integer
|
||||
// description: Number of items to return.
|
||||
// default: 20
|
||||
// in: query
|
||||
// required: false
|
||||
// max: 80
|
||||
// min: 0
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// name: tokens
|
||||
// description: Array of token info entries.
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/tokenInfo"
|
||||
// headers:
|
||||
// Link:
|
||||
// type: string
|
||||
// description: Links to the next and previous queries.
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '400':
|
||||
// description: bad request
|
||||
func (m *Module) TokensInfoGETHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeReadAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
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,
|
||||
0, // min limit
|
||||
80, // max limit
|
||||
20, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
resp, errWithCode := m.processor.Account().TokensGet(
|
||||
c.Request.Context(),
|
||||
authed.User.ID,
|
||||
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)
|
||||
}
|
69
internal/api/client/tokens/tokensget_test.go
Normal file
69
internal/api/client/tokens/tokensget_test.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
// 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 tokens_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tokens"
|
||||
)
|
||||
|
||||
type TokensGetTestSuite struct {
|
||||
TokensStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *TokensGetTestSuite) TestTokensGet() {
|
||||
var (
|
||||
testPath = "/api" + tokens.BasePath
|
||||
)
|
||||
|
||||
out, code := suite.req(
|
||||
http.MethodGet,
|
||||
testPath,
|
||||
suite.tokens.TokensInfoGETHandler,
|
||||
nil,
|
||||
)
|
||||
|
||||
suite.Equal(http.StatusOK, code)
|
||||
suite.Equal(`[
|
||||
{
|
||||
"id": "01JN0X2D9GJTZQ5KYPYFWN16QW",
|
||||
"created_at": "2025-02-26T10:33:04.560Z",
|
||||
"scope": "push",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "01F8MGTQW4DKTDF8SW5CT9HYGA",
|
||||
"created_at": "2021-06-20T10:53:00.164Z",
|
||||
"scope": "read write push",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
}
|
||||
}
|
||||
]`, out)
|
||||
}
|
||||
|
||||
func TestTokensGetTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(TokensGetTestSuite))
|
||||
}
|
|
@ -33,3 +33,25 @@ type Token struct {
|
|||
// example: 1627644520
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// TokenInfo represents metadata about one user-level access token.
|
||||
// The actual access token itself will never be sent via the API.
|
||||
//
|
||||
// swagger:model tokenInfo
|
||||
type TokenInfo struct {
|
||||
// Database ID of this token.
|
||||
// example: 01JMW7QBAZYZ8T8H73PCEX12XG
|
||||
ID string `json:"id"`
|
||||
// When the token was created (ISO 8601 Datetime).
|
||||
// example: 2021-07-30T09:20:25+00:00
|
||||
CreatedAt string `json:"created_at"`
|
||||
// Approximate time (accurate to within an hour) when the token was last used (ISO 8601 Datetime).
|
||||
// Omitted if token has never been used, or it is not known when it was last used (eg., it was last used before tracking "last_used" became a thing).
|
||||
// example: 2021-07-30T09:20:25+00:00
|
||||
LastUsed string `json:"last_used,omitempty"`
|
||||
// OAuth scopes granted by the token, space-separated.
|
||||
// example: read write admin
|
||||
Scope string `json:"scope"`
|
||||
// Application used to create this token.
|
||||
Application *Application `json:"application"`
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
type Application interface {
|
||||
|
@ -39,6 +40,9 @@ type Application interface {
|
|||
// GetAllTokens fetches all client oauth tokens from database.
|
||||
GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error)
|
||||
|
||||
// GetAccessTokens allows paging through a user's access (ie., user-level) tokens.
|
||||
GetAccessTokens(ctx context.Context, userID string, page *paging.Page) ([]*gtsmodel.Token, error)
|
||||
|
||||
// GetTokenByID fetches the client oauth token from database with ID.
|
||||
GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error)
|
||||
|
||||
|
|
|
@ -19,8 +19,11 @@
|
|||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
||||
"github.com/uptrace/bun"
|
||||
|
@ -139,6 +142,74 @@ func(uncached []string) ([]*gtsmodel.Token, error) {
|
|||
return tokens, nil
|
||||
}
|
||||
|
||||
func (a *applicationDB) GetAccessTokens(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
page *paging.Page,
|
||||
) ([]*gtsmodel.Token, error) {
|
||||
var (
|
||||
// Get paging params.
|
||||
minID = page.GetMin()
|
||||
maxID = page.GetMax()
|
||||
limit = page.GetLimit()
|
||||
order = page.GetOrder()
|
||||
|
||||
// Make educated guess for slice size.
|
||||
tokenIDs = make([]string, 0, limit)
|
||||
)
|
||||
|
||||
// Ensure user ID.
|
||||
if userID == "" {
|
||||
return nil, gtserror.New("userID not set")
|
||||
}
|
||||
|
||||
q := a.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("tokens"), bun.Ident("token")).
|
||||
Column("token.id").
|
||||
Where("? = ?", bun.Ident("token.user_id"), userID).
|
||||
Where("? != ?", bun.Ident("token.access"), "")
|
||||
|
||||
if maxID != "" {
|
||||
// Return only tokens LOWER (ie., older) than maxID.
|
||||
q = q.Where("? < ?", bun.Ident("token.id"), maxID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
// Return only tokens HIGHER (ie., newer) than minID.
|
||||
q = q.Where("? > ?", bun.Ident("token.id"), minID)
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if order == paging.OrderAscending {
|
||||
// Page up.
|
||||
q = q.Order("token.id ASC")
|
||||
} else {
|
||||
// Page down.
|
||||
q = q.Order("token.id DESC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &tokenIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(tokenIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want tokens
|
||||
// to be sorted by ID desc (ie., newest to
|
||||
// oldest), so reverse ids slice.
|
||||
if order == paging.OrderAscending {
|
||||
slices.Reverse(tokenIDs)
|
||||
}
|
||||
|
||||
return a.getTokensByIDs(ctx, tokenIDs)
|
||||
}
|
||||
|
||||
func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmodel.Token, error) {
|
||||
return a.getTokenBy(
|
||||
"ID",
|
||||
|
@ -149,6 +220,37 @@ func(t *gtsmodel.Token) error {
|
|||
)
|
||||
}
|
||||
|
||||
func (a *applicationDB) getTokensByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Token, error) {
|
||||
tokens, err := a.state.Caches.DB.Token.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Token, error) {
|
||||
// Preallocate expected length of uncached tokens.
|
||||
tokens := make([]*gtsmodel.Token, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) token IDs.
|
||||
if err := a.db.NewSelect().
|
||||
Model(&tokens).
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reorder the tokens by their
|
||||
// IDs to ensure in correct order.
|
||||
getID := func(t *gtsmodel.Token) string { return t.ID }
|
||||
xslices.OrderBy(tokens, ids, getID)
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (a *applicationDB) GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) {
|
||||
return a.getTokenBy(
|
||||
"Code",
|
||||
|
|
122
internal/processing/account/tokens.go
Normal file
122
internal/processing/account/tokens.go
Normal file
|
@ -0,0 +1,122 @@
|
|||
// 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 account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
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/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
func (p *Processor) TokensGet(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
page *paging.Page,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
tokens, err := p.state.DB.GetAccessTokens(ctx, userID, page)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting tokens: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(tokens)
|
||||
if count == 0 {
|
||||
return paging.EmptyResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
// Get the lowest and highest
|
||||
// ID values, used for paging.
|
||||
lo = tokens[count-1].ID
|
||||
hi = tokens[0].ID
|
||||
|
||||
// Best-guess items length.
|
||||
items = make([]interface{}, 0, count)
|
||||
)
|
||||
|
||||
for _, token := range tokens {
|
||||
tokenInfo, err := p.converter.TokenToAPITokenInfo(ctx, token)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error converting token to api token info: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Append req to return items.
|
||||
items = append(items, tokenInfo)
|
||||
}
|
||||
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/tokens",
|
||||
Next: page.Next(lo, hi),
|
||||
Prev: page.Prev(lo, hi),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (p *Processor) TokenGet(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
tokenID string,
|
||||
) (*apimodel.TokenInfo, gtserror.WithCode) {
|
||||
token, err := p.state.DB.GetTokenByID(ctx, tokenID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting token %s: %w", tokenID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
err := gtserror.Newf("token %s not found in the db", tokenID)
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
|
||||
if token.UserID != userID {
|
||||
err := gtserror.Newf("token %s does not belong to user %s", tokenID, userID)
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
|
||||
tokenInfo, err := p.converter.TokenToAPITokenInfo(ctx, token)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting token to api token info: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
func (p *Processor) TokenInvalidate(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
tokenID string,
|
||||
) (*apimodel.TokenInfo, gtserror.WithCode) {
|
||||
tokenInfo, errWithCode := p.TokenGet(ctx, userID, tokenID)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
if err := p.state.DB.DeleteTokenByID(ctx, tokenID); err != nil {
|
||||
err := gtserror.Newf("db error deleting token %s: %w", tokenID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
|
@ -3068,3 +3068,39 @@ func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription(
|
|||
Standard: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Converter) TokenToAPITokenInfo(
|
||||
ctx context.Context,
|
||||
token *gtsmodel.Token,
|
||||
) (*apimodel.TokenInfo, error) {
|
||||
createdAt, err := id.TimeFromULID(token.ID)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error parsing time from token id: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastUsed string
|
||||
if !token.LastUsed.IsZero() {
|
||||
lastUsed = util.FormatISO8601(token.LastUsed)
|
||||
}
|
||||
|
||||
application, err := c.state.DB.GetApplicationByClientID(ctx, token.ClientID)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("db error getting application with client id %s: %w", token.ClientID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiApplication, err := c.AppToAPIAppPublic(ctx, application)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting application to api application: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &apimodel.TokenInfo{
|
||||
ID: token.ID,
|
||||
CreatedAt: util.FormatISO8601(createdAt),
|
||||
LastUsed: lastUsed,
|
||||
Scope: token.Scope,
|
||||
Application: apiApplication,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -171,7 +171,8 @@ export const gtsApi = createApi({
|
|||
"InteractionRequest",
|
||||
"DomainPermissionDraft",
|
||||
"DomainPermissionExclude",
|
||||
"DomainPermissionSubscription"
|
||||
"DomainPermissionSubscription",
|
||||
"TokenInfo",
|
||||
],
|
||||
endpoints: (build) => ({
|
||||
instanceV1: build.query<InstanceV1, void>({
|
||||
|
|
73
web/source/settings/lib/query/user/tokens.ts
Normal file
73
web/source/settings/lib/query/user/tokens.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
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 {
|
||||
SearchTokenInfoParams,
|
||||
SearchTokenInfoResp,
|
||||
TokenInfo,
|
||||
} from "../../types/tokeninfo";
|
||||
import { gtsApi } from "../gts-api";
|
||||
import parse from "parse-link-header";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
searchTokenInfo: build.query<SearchTokenInfoResp, SearchTokenInfoParams>({
|
||||
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/v1/tokens${query}`
|
||||
};
|
||||
},
|
||||
// Headers required for paging.
|
||||
transformResponse: (apiResp: TokenInfo[], meta) => {
|
||||
const tokens = apiResp;
|
||||
const linksStr = meta?.response?.headers.get("Link");
|
||||
const links = parse(linksStr);
|
||||
return { tokens, links };
|
||||
},
|
||||
providesTags: [{ type: "TokenInfo", id: "TRANSFORMED" }]
|
||||
}),
|
||||
invalidateToken: build.mutation<any, string>({
|
||||
query: (id) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/tokens/${id}/invalidate`,
|
||||
}),
|
||||
invalidatesTags: (res) =>
|
||||
res
|
||||
? [{ type: "TokenInfo", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }]
|
||||
: [{ type: "TokenInfo", id: "TRANSFORMED" }]
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
export const {
|
||||
useLazySearchTokenInfoQuery,
|
||||
useInvalidateTokenMutation,
|
||||
} = extended;
|
62
web/source/settings/lib/types/tokeninfo.ts
Normal file
62
web/source/settings/lib/types/tokeninfo.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
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 { Links } from "parse-link-header";
|
||||
|
||||
export interface TokenInfo {
|
||||
id: string;
|
||||
created_at: string;
|
||||
last_used?: string;
|
||||
scope: string;
|
||||
application: {
|
||||
name: string;
|
||||
website?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for GET to /api/v1/tokens.
|
||||
*/
|
||||
export interface SearchTokenInfoParams {
|
||||
/**
|
||||
* If set, show only items older (ie., lower) than the given ID.
|
||||
* Item with the given ID will not be included in response.
|
||||
*/
|
||||
max_id?: string;
|
||||
/**
|
||||
* If set, show only items newer (ie., higher) than the given ID.
|
||||
* Item with the given ID will not be included in response.
|
||||
*/
|
||||
since_id?: string;
|
||||
/**
|
||||
* If set, show only items *immediately newer* than the given ID.
|
||||
* Item with the given ID will not be included in response.
|
||||
*/
|
||||
min_id?: string;
|
||||
/**
|
||||
* If set, limit returned items to this number.
|
||||
* Else, fall back to GtS API defaults.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchTokenInfoResp {
|
||||
tokens: TokenInfo[];
|
||||
links: Links | null;
|
||||
}
|
|
@ -1468,6 +1468,33 @@ button.tab-button {
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tokens-view {
|
||||
.token-info {
|
||||
.info-list {
|
||||
border: none;
|
||||
width: 100%;
|
||||
|
||||
.info-list-entry {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> .info-list-entry > .monospace {
|
||||
font-size: large;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 0.5rem;
|
||||
> .mutation-button
|
||||
> button {
|
||||
font-size: 1rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instance-rules {
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
|
|
|
@ -63,6 +63,11 @@ export default function UserMenu() {
|
|||
itemUrl="export-import"
|
||||
icon="fa-floppy-o"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Access Tokens"
|
||||
itemUrl="tokens"
|
||||
icon="fa-certificate"
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import EmailPassword from "./emailpassword";
|
|||
import ExportImport from "./export-import";
|
||||
import InteractionRequests from "./interactions";
|
||||
import InteractionRequestDetail from "./interactions/detail";
|
||||
import Tokens from "./tokens";
|
||||
|
||||
/**
|
||||
* - /settings/user/profile
|
||||
|
@ -35,6 +36,7 @@ import InteractionRequestDetail from "./interactions/detail";
|
|||
* - /settings/user/emailpassword
|
||||
* - /settings/user/migration
|
||||
* - /settings/user/export-import
|
||||
* - /settings/user/tokens
|
||||
* - /settings/users/interaction_requests
|
||||
*/
|
||||
export default function UserRouter() {
|
||||
|
@ -52,6 +54,7 @@ export default function UserRouter() {
|
|||
<Route path="/emailpassword" component={EmailPassword} />
|
||||
<Route path="/migration" component={UserMigration} />
|
||||
<Route path="/export-import" component={ExportImport} />
|
||||
<Route path="/tokens" component={Tokens} />
|
||||
<InteractionRequestsRouter />
|
||||
<Route><Redirect to="/profile" /></Route>
|
||||
</Switch>
|
||||
|
|
50
web/source/settings/views/user/tokens/index.tsx
Normal file
50
web/source/settings/views/user/tokens/index.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
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 TokensSearchForm from "./search";
|
||||
|
||||
export default function Tokens() {
|
||||
return (
|
||||
<div className="tokens-view">
|
||||
<div className="form-section-docs">
|
||||
<h1>Access Tokens</h1>
|
||||
<p>
|
||||
On this page you can search through access tokens owned by applications that you have authorized to
|
||||
access your account and/or perform actions on your behalf. You can invalidate a token by clicking on
|
||||
the invalidate button under a token. This will remove the token from the database.
|
||||
<br/><br/>
|
||||
<strong>
|
||||
If you see any tokens from applications that you do not recognize, or do not remember authorizing to access
|
||||
your account, then you should invalidate them, and consider changing your password as soon as possible.
|
||||
</strong>
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#access-tokens"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about managing your access tokens (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<TokensSearchForm />
|
||||
</div>
|
||||
);
|
||||
}
|
214
web/source/settings/views/user/tokens/search.tsx
Normal file
214
web/source/settings/views/user/tokens/search.tsx
Normal file
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useEffect, useMemo } from "react";
|
||||
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { PageableList } from "../../../components/pageable-list";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { Select } from "../../../components/form/inputs";
|
||||
import { useInvalidateTokenMutation, useLazySearchTokenInfoQuery } from "../../../lib/query/user/tokens";
|
||||
import { TokenInfo } from "../../../lib/types/tokeninfo";
|
||||
|
||||
export default function TokensSearchForm() {
|
||||
const [ location, setLocation ] = useLocation();
|
||||
const search = useSearch();
|
||||
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
const [ searchTokenInfo, searchRes ] = useLazySearchTokenInfoQuery();
|
||||
|
||||
// Populate search form using values from
|
||||
// urlQueryParams, to allow paging.
|
||||
const form = {
|
||||
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
|
||||
};
|
||||
|
||||
// On mount, trigger search.
|
||||
useEffect(() => {
|
||||
searchTokenInfo(Object.fromEntries(urlQueryParams), true);
|
||||
}, [urlQueryParams, searchTokenInfo]);
|
||||
|
||||
// Rather than triggering the search directly,
|
||||
// the "submit" button changes the location
|
||||
// based on form field params, and lets the
|
||||
// useEffect hook above actually do the search.
|
||||
function submitQuery(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Parse query parameters.
|
||||
const entries = Object.entries(form).map(([k, v]) => {
|
||||
// Take only defined form fields.
|
||||
if (v.value === undefined) {
|
||||
return null;
|
||||
} else if (typeof v.value === "string" && v.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [[k, v.value.toString()]];
|
||||
}).flatMap(kv => {
|
||||
// Remove any nulls.
|
||||
return kv !== null ? kv : [];
|
||||
});
|
||||
|
||||
const searchParams = new URLSearchParams(entries);
|
||||
setLocation(location + "?" + searchParams.toString());
|
||||
}
|
||||
|
||||
// Function to map an item to a list entry.
|
||||
function itemToEntry(tokenInfo: TokenInfo): ReactNode {
|
||||
return (
|
||||
<TokenInfoListEntry
|
||||
key={tokenInfo.id}
|
||||
tokenInfo={tokenInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={submitQuery}
|
||||
// Prevent password managers
|
||||
// trying to fill in fields.
|
||||
autoComplete="off"
|
||||
>
|
||||
<Select
|
||||
field={form.limit}
|
||||
label="Items per page"
|
||||
options={
|
||||
<>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="0">No limit / show all</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label={"Search"}
|
||||
result={searchRes}
|
||||
/>
|
||||
</form>
|
||||
<PageableList
|
||||
isLoading={searchRes.isLoading}
|
||||
isFetching={searchRes.isFetching}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
items={searchRes.data?.tokens}
|
||||
itemToEntry={itemToEntry}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage={<b>No tokens found.</b>}
|
||||
prevNextLinks={searchRes.data?.links}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TokenInfoListEntryProps {
|
||||
tokenInfo: TokenInfo;
|
||||
}
|
||||
|
||||
function TokenInfoListEntry({ tokenInfo }: TokenInfoListEntryProps) {
|
||||
const appWebsite = useMemo(() => {
|
||||
if (!tokenInfo.application.website) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to parse nicely and return link.
|
||||
const websiteURL = new URL(tokenInfo.application.website);
|
||||
const websiteURLStr = websiteURL.toString();
|
||||
return (
|
||||
<a
|
||||
href={websiteURLStr}
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer noopener"
|
||||
>{websiteURLStr}</a>
|
||||
);
|
||||
} catch {
|
||||
// Fall back to returning string.
|
||||
return tokenInfo.application.website;
|
||||
}
|
||||
}, [tokenInfo.application.website]);
|
||||
|
||||
const created = useMemo(() => {
|
||||
const createdAt = new Date(tokenInfo.created_at);
|
||||
return <time dateTime={tokenInfo.created_at}>{createdAt.toDateString()}</time>;
|
||||
}, [tokenInfo.created_at]);
|
||||
|
||||
const lastUsed = useMemo(() => {
|
||||
if (!tokenInfo.last_used) {
|
||||
return "unknown/never";
|
||||
}
|
||||
|
||||
const lastUsed = new Date(tokenInfo.last_used);
|
||||
return <time dateTime={tokenInfo.last_used}>{lastUsed.toDateString()}</time>;
|
||||
}, [tokenInfo.last_used]);
|
||||
|
||||
const [ invalidate, invalidateResult ] = useInvalidateTokenMutation();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`token-info entry`}
|
||||
aria-label={`${tokenInfo.application.name}, scope: ${tokenInfo.scope}`}
|
||||
title={`${tokenInfo.application.name}, scope: ${tokenInfo.scope}`}
|
||||
>
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>App name:</dt>
|
||||
<dd className="text-cutoff">{tokenInfo.application.name}</dd>
|
||||
</div>
|
||||
{ appWebsite &&
|
||||
<div className="info-list-entry">
|
||||
<dt>App website:</dt>
|
||||
<dd className="text-cutoff">{appWebsite}</dd>
|
||||
</div>
|
||||
}
|
||||
<div className="info-list-entry">
|
||||
<dt>Scope:</dt>
|
||||
<dd className="text-cutoff monospace">{tokenInfo.scope}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Created:</dt>
|
||||
<dd className="text-cutoff">{created}</dd>
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Last used:</dt>
|
||||
<dd className="text-cutoff">{lastUsed}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="action-buttons">
|
||||
<MutationButton
|
||||
label={`Invalidate token`}
|
||||
title={`Invalidate token`}
|
||||
type="button"
|
||||
className="button danger"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
invalidate(tokenInfo.id);
|
||||
}}
|
||||
disabled={false}
|
||||
showError={true}
|
||||
result={invalidateResult}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue