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
3b1b842890
commit
fde7b5723c
17 changed files with 1065 additions and 1 deletions
|
@ -3369,6 +3369,37 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: ThreadContext
|
x-go-name: ThreadContext
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
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:
|
user:
|
||||||
properties:
|
properties:
|
||||||
admin:
|
admin:
|
||||||
|
@ -11642,6 +11673,124 @@ paths:
|
||||||
summary: See public statuses that use the given hashtag (case insensitive).
|
summary: See public statuses that use the given hashtag (case insensitive).
|
||||||
tags:
|
tags:
|
||||||
- timelines
|
- 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:
|
/api/v1/user:
|
||||||
get:
|
get:
|
||||||
operationId: getUser
|
operationId: getUser
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
|
"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/api/client/user"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||||
|
@ -99,6 +100,7 @@ type Client struct {
|
||||||
streaming *streaming.Module // api/v1/streaming
|
streaming *streaming.Module // api/v1/streaming
|
||||||
tags *tags.Module // api/v1/tags
|
tags *tags.Module // api/v1/tags
|
||||||
timelines *timelines.Module // api/v1/timelines
|
timelines *timelines.Module // api/v1/timelines
|
||||||
|
tokens *tokens.Module // api/v1/tokens
|
||||||
user *user.Module // api/v1/user
|
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.streaming.Route(h)
|
||||||
c.tags.Route(h)
|
c.tags.Route(h)
|
||||||
c.timelines.Route(h)
|
c.timelines.Route(h)
|
||||||
|
c.tokens.Route(h)
|
||||||
c.user.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),
|
streaming: streaming.New(p, time.Second*30, 4096),
|
||||||
tags: tags.New(p),
|
tags: tags.New(p),
|
||||||
timelines: timelines.New(p),
|
timelines: timelines.New(p),
|
||||||
|
tokens: tokens.New(p),
|
||||||
user: user.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) 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)
|
||||||
|
}
|
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)
|
||||||
|
}
|
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)
|
||||||
|
}
|
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)
|
||||||
|
}
|
|
@ -33,3 +33,25 @@ type Token struct {
|
||||||
// example: 1627644520
|
// example: 1627644520
|
||||||
CreatedAt int64 `json:"created_at"`
|
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"
|
"context"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Application interface {
|
type Application interface {
|
||||||
|
@ -39,6 +40,9 @@ type Application interface {
|
||||||
// GetAllTokens fetches all client oauth tokens from database.
|
// GetAllTokens fetches all client oauth tokens from database.
|
||||||
GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error)
|
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 fetches the client oauth token from database with ID.
|
||||||
GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error)
|
GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error)
|
||||||
|
|
||||||
|
|
|
@ -19,8 +19,11 @@
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
|
@ -139,6 +142,74 @@ func(uncached []string) ([]*gtsmodel.Token, error) {
|
||||||
return tokens, nil
|
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) {
|
func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmodel.Token, error) {
|
||||||
return a.getTokenBy(
|
return a.getTokenBy(
|
||||||
"ID",
|
"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) {
|
func (a *applicationDB) GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) {
|
||||||
return a.getTokenBy(
|
return a.getTokenBy(
|
||||||
"Code",
|
"Code",
|
||||||
|
|
|
@ -171,7 +171,8 @@ export const gtsApi = createApi({
|
||||||
"InteractionRequest",
|
"InteractionRequest",
|
||||||
"DomainPermissionDraft",
|
"DomainPermissionDraft",
|
||||||
"DomainPermissionExclude",
|
"DomainPermissionExclude",
|
||||||
"DomainPermissionSubscription"
|
"DomainPermissionSubscription",
|
||||||
|
"TokenInfo",
|
||||||
],
|
],
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
instanceV1: build.query<InstanceV1, void>({
|
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;
|
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 {
|
.instance-rules {
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -63,6 +63,11 @@ export default function UserMenu() {
|
||||||
itemUrl="export-import"
|
itemUrl="export-import"
|
||||||
icon="fa-floppy-o"
|
icon="fa-floppy-o"
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
name="App Tokens"
|
||||||
|
itemUrl="tokens"
|
||||||
|
icon="fa-certificate"
|
||||||
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import EmailPassword from "./emailpassword";
|
||||||
import ExportImport from "./export-import";
|
import ExportImport from "./export-import";
|
||||||
import InteractionRequests from "./interactions";
|
import InteractionRequests from "./interactions";
|
||||||
import InteractionRequestDetail from "./interactions/detail";
|
import InteractionRequestDetail from "./interactions/detail";
|
||||||
|
import Tokens from "./tokens";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - /settings/user/profile
|
* - /settings/user/profile
|
||||||
|
@ -35,6 +36,7 @@ import InteractionRequestDetail from "./interactions/detail";
|
||||||
* - /settings/user/emailpassword
|
* - /settings/user/emailpassword
|
||||||
* - /settings/user/migration
|
* - /settings/user/migration
|
||||||
* - /settings/user/export-import
|
* - /settings/user/export-import
|
||||||
|
* - /settings/user/tokens
|
||||||
* - /settings/users/interaction_requests
|
* - /settings/users/interaction_requests
|
||||||
*/
|
*/
|
||||||
export default function UserRouter() {
|
export default function UserRouter() {
|
||||||
|
@ -52,6 +54,7 @@ export default function UserRouter() {
|
||||||
<Route path="/emailpassword" component={EmailPassword} />
|
<Route path="/emailpassword" component={EmailPassword} />
|
||||||
<Route path="/migration" component={UserMigration} />
|
<Route path="/migration" component={UserMigration} />
|
||||||
<Route path="/export-import" component={ExportImport} />
|
<Route path="/export-import" component={ExportImport} />
|
||||||
|
<Route path="/tokens" component={Tokens} />
|
||||||
<InteractionRequestsRouter />
|
<InteractionRequestsRouter />
|
||||||
<Route><Redirect to="/profile" /></Route>
|
<Route><Redirect to="/profile" /></Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
39
web/source/settings/views/user/tokens/index.tsx
Normal file
39
web/source/settings/views/user/tokens/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
180
web/source/settings/views/user/tokens/search.tsx
Normal file
180
web/source/settings/views/user/tokens/search.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue