[feature] Add token review / delete to backend + settings panel

This commit is contained in:
tobi 2025-03-01 11:54:11 +01:00
parent 3b1b842890
commit fde7b5723c
17 changed files with 1065 additions and 1 deletions

View file

@ -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

View file

@ -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),
}
}

View 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) AccountGETHandler(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)
}

View 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)
}

View 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)
}

View 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)
}

View file

@ -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"`
}

View file

@ -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)

View file

@ -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",

View file

@ -171,7 +171,8 @@ export const gtsApi = createApi({
"InteractionRequest",
"DomainPermissionDraft",
"DomainPermissionExclude",
"DomainPermissionSubscription"
"DomainPermissionSubscription",
"TokenInfo",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({

View 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;

View 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;
}

View file

@ -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;

View file

@ -63,6 +63,11 @@ export default function UserMenu() {
itemUrl="export-import"
icon="fa-floppy-o"
/>
<MenuItem
name="App Tokens"
itemUrl="tokens"
icon="fa-certificate"
/>
</MenuItem>
);
}

View file

@ -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>

View file

@ -0,0 +1,39 @@
/*
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>App 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.
<br/>You can invalidate a token by clicking on the invalidate button under a token. This will remove the token from the database.
<br/>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 log in again with that application.
<br/>In cases where you've logged into an application multiple times, or logged in with multiple devices or browsers, you may see multiple tokens for one application. This is normal!
<br/>That said, feel free to invalidate old tokens that are never used, it's good security practice and it's fun to click the big red button.
</p>
</div>
<TokensSearchForm />
</div>
);
}

View file

@ -0,0 +1,180 @@
/*
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 created = new Date(tokenInfo.created_at).toLocaleString();
const lastUsed = tokenInfo.last_used ? new Date(tokenInfo.last_used).toDateString(): "unknown/never";
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>
{ tokenInfo.application.website &&
<div className="info-list-entry">
<dt>App website:</dt>
<dd className="text-cutoff">{tokenInfo.application.website}</dd>
</div>
}
<div className="info-list-entry">
<dt>Token scope:</dt>
<dd className="text-cutoff monospace">{tokenInfo.scope}</dd>
</div>
<div className="info-list-entry">
<dt>Token created at:</dt>
<dd className="text-cutoff">{created}</dd>
</div>
<div className="info-list-entry">
<dt>Token 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>
);
}