From 41cde6707236ac94a0bae2f7b111a7207f9d4fd2 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Tue, 30 Jun 2026 15:40:41 +0800 Subject: [PATCH 1/5] feat(security): application token refresh --- api/application.go | 73 +++++++++++ api/application_test.go | 44 +++++++ docs/spec.json | 130 ++++++++++++++++++++ model/security.go | 37 ++++++ router/router.go | 1 + ui/src/application/AddApplicationDialog.tsx | 9 +- ui/src/application/AppStore.ts | 7 ++ ui/src/application/Applications.tsx | 69 +++++++++-- ui/src/tests/application.test.ts | 18 ++- 9 files changed, 370 insertions(+), 18 deletions(-) create mode 100644 model/security.go diff --git a/api/application.go b/api/application.go index 3ff6112f..1cfc8c25 100644 --- a/api/application.go +++ b/api/application.go @@ -280,6 +280,79 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { }) } +// UpdateApplicationSecurity performs security updates on an application. +// swagger:operation PUT /application/{id}/security application updateAppSecurity +// +// Perform security updates on an application. +// +// Requires elevated authentication. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// parameters: +// - name: body +// in: body +// description: security update action descriptor +// required: true +// schema: +// $ref: "#/definitions/SecurityUpdateAction" +// - name: id +// in: path +// description: the application id +// required: true +// type: integer +// format: int64 +// responses: +// 200: +// description: Ok +// schema: +// $ref: "#/definitions/SecurityUpdateActionResponse" +// 400: +// description: Bad Request +// schema: +// $ref: "#/definitions/Error" +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 403: +// description: Forbidden +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +// 500: +// description: Server Error +// schema: +// $ref: "#/definitions/Error" +func (a *ApplicationAPI) UpdateApplicationSecurity(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + app, err := a.DB.GetApplicationByID(id) + if success := successOrAbort(ctx, 500, err); !success { + return + } + action := model.SecurityUpdateAction{} + response := model.SecurityUpdateActionResponse{} + if err := ctx.Bind(&action); err == nil { + if action.RegenerateToken { + tokenPublic, tokenPrivate := generateApplicationToken() + app.Token = tokenPublic + response.RegenerateToken = &model.RegenerateTokenResponse{ + Token: tokenPrivate, + } + } + if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success { + return + } + ctx.JSON(200, response) + } + }) +} + // UploadApplicationImage uploads an image for an application. // swagger:operation POST /application/{id}/image application uploadAppImage // diff --git a/api/application_test.go b/api/application_test.go index 4787cc6f..94fe8907 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -143,6 +143,50 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar } } +func (s *ApplicationSuite) Test_UpdateApplicationSecurity_regenerateToken() { + s.db.User(5).App(1) + test.WithUser(s.ctx, 5) + + oldToken, err := s.db.GetApplicationByID(1) + assert.NoError(s.T(), err) + s.ctx.Request = httptest.NewRequest("PUT", "/application/1/security", bytes.NewBufferString(`{"regenerateToken": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.UpdateApplicationSecurity(s.ctx) + assert.Equal(s.T(), 200, s.recorder.Code) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.SecurityUpdateActionResponse + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + assert.Equal(s.T(), &model.SecurityUpdateActionResponse{ + RegenerateToken: &model.RegenerateTokenResponse{ + Token: got.RegenerateToken.Token, + }, + }, &got) + newToken, err := s.db.GetApplicationByID(1) + assert.NoError(s.T(), err) + assert.NotEqual(s.T(), oldToken.Token, newToken.Token) +} + +func (s *ApplicationSuite) Test_UpdateApplicationSecurity_isNoOpIfNilAction() { + s.db.User(5).App(1) + test.WithUser(s.ctx, 5) + + oldToken, err := s.db.GetApplicationByID(1) + assert.NoError(s.T(), err) + s.ctx.Request = httptest.NewRequest("PUT", "/application/1/security", bytes.NewBufferString(`{}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + s.a.UpdateApplicationSecurity(s.ctx) + assert.Equal(s.T(), 200, s.recorder.Code) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + assert.Equal(s.T(), "{}", string(bodyBytes)) + newToken, err := s.db.GetApplicationByID(1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), oldToken, newToken) +} + func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { s.db.User(2) s.db.User(5).App(5) diff --git a/docs/spec.json b/docs/spec.json index 498233b7..41059517 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -588,6 +588,93 @@ } } }, + "/application/{id}/security": { + "put": { + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "description": "Requires elevated authentication.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "application" + ], + "summary": "Perform security updates on an application.", + "operationId": "updateAppSecurity", + "parameters": [ + { + "description": "security update action descriptor", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SecurityUpdateAction" + } + }, + { + "type": "integer", + "format": "int64", + "description": "the application id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/SecurityUpdateActionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "Server Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/auth/local/login": { "post": { "security": [ @@ -3192,6 +3279,49 @@ "x-go-name": "PluginConfExternal", "x-go-package": "github.com/gotify/server/v2/model" }, + "RegenerateTokenResponse": { + "description": "The RegenerateTokenResponse holds information about the response to the regenerate token action.", + "type": "object", + "title": "RegenerateTokenResponse Model", + "required": [ + "token" + ], + "properties": { + "token": { + "description": "The new token.", + "type": "string", + "x-go-name": "Token", + "readOnly": true, + "example": "gtfya.e2NcJK7AenXBPIRB3S03JsBlmy0V6xP8h0hwSiAJae8" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, + "SecurityUpdateAction": { + "description": "The SecurityUpdateAction describes the details of a requested security update.", + "type": "object", + "title": "SecurityUpdateAction Model", + "properties": { + "regenerateToken": { + "description": "Whether to regenerate the token. Your client token must be elevated to perform this action.", + "type": "boolean", + "x-go-name": "RegenerateToken", + "example": true + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, + "SecurityUpdateActionResponse": { + "description": "The SecurityUpdateActionResponse holds information about the response to a security update request.", + "type": "object", + "title": "SecurityUpdateActionResponse Model", + "properties": { + "regenerateToken": { + "$ref": "#/definitions/RegenerateTokenResponse" + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, "UpdateUserExternal": { "description": "Used for updating a user.", "type": "object", diff --git a/model/security.go b/model/security.go new file mode 100644 index 00000000..4afb5da6 --- /dev/null +++ b/model/security.go @@ -0,0 +1,37 @@ +package model + +// SecurityUpdateAction Model +// +// The SecurityUpdateAction describes the details of a requested security update. +// +// swagger:model SecurityUpdateAction +type SecurityUpdateAction struct { + // Whether to regenerate the token. Your client token must be elevated to perform this action. + // + // example: true + RegenerateToken bool `form:"regenerateToken" query:"regenerateToken" json:"regenerateToken"` +} + +// SecurityUpdateActionResponse Model +// +// The SecurityUpdateActionResponse holds information about the response to a security update request. +// +// swagger:model SecurityUpdateActionResponse +type SecurityUpdateActionResponse struct { + // The response to the regenerate token action. Only present if the regenerate token action was requested. + RegenerateToken *RegenerateTokenResponse `json:"regenerateToken,omitempty"` +} + +// RegenerateTokenResponse Model +// +// The RegenerateTokenResponse holds information about the response to the regenerate token action. +// +// swagger:model RegenerateTokenResponse +type RegenerateTokenResponse struct { + // The new token. + // + // example: gtfya.e2NcJK7AenXBPIRB3S03JsBlmy0V6xP8h0hwSiAJae8 + // read only: true + // required: true + Token string `json:"token"` +} diff --git a/router/router.go b/router/router.go index c7c1f24c..a3293395 100644 --- a/router/router.go +++ b/router/router.go @@ -232,6 +232,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co clientElevated.Use(authentication.RequireElevatedClient) clientElevated.POST("/client/:id/elevate", clientHandler.ElevateClient) clientElevated.DELETE("/application/:id", applicationHandler.DeleteApplication) + clientElevated.PUT("/application/:id/security", applicationHandler.UpdateApplicationSecurity) clientElevated.DELETE("/client/:id", clientHandler.DeleteClient) clientElevated.POST("/current/user/password", userHandler.ChangePassword) } diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index 6317a043..53d69b3c 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -13,12 +13,13 @@ import {copyToClipboard} from '../clipboard'; import {useStores} from '../stores'; interface IProps { + fKnownToken?: string; fClose: VoidFunction; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; } -export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { - const [returnToken, setReturnToken] = useState(''); +export const AddApplicationDialog = ({fClose, fOnSubmit, fKnownToken}: IProps) => { + const [returnToken, setReturnToken] = useState(fKnownToken || ''); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [defaultPriority, setDefaultPriority] = useState(0); @@ -32,7 +33,9 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { return ( - Create an application + + {fKnownToken ? 'Update an application' : 'Create an application'} + {returnToken ? ( <> diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index 9fbb5586..7a805f69 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -36,6 +36,13 @@ export class AppStore extends BaseStore { this.snack('Application image updated'); }; + public async rekey(id: number): Promise { + const response = await axios.put(`${config.get('url')}application/${id}/security`, { + regenerateToken: true, + }); + return response.data.regenerateToken.token; + } + public async deleteImage(id: number): Promise { try { await axios.delete(`${config.get('url')}application/${id}/image`); diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index a535b58c..b9e7a973 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -1,4 +1,5 @@ import React, {ChangeEvent, useEffect, useRef, useState} from 'react'; +import Key from '@mui/icons-material/Key'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; @@ -59,12 +60,14 @@ const useStyles = makeStyles()((theme) => ({ })); const Applications = observer(() => { - const {appStore} = useStores(); + const {appStore, snackManager} = useStores(); const apps = appStore.getItems(); const [toDeleteApp, setToDeleteApp] = useState(); const [toDeleteImage, setToDeleteImage] = useState(); const [toUpdateApp, setToUpdateApp] = useState(); + const [toRekeyApp, setToRekeyApp] = useState(); const [createDialog, setCreateDialog] = useState(false); + const [createDialogKnownToken, setCreateDialogKnownToken] = useState(); const fileInputRef = useRef(null); const uploadId = useRef(-1); @@ -106,7 +109,10 @@ const Applications = observer(() => { id="create-app" variant="contained" color="primary" - onClick={() => setCreateDialog(true)}> + onClick={() => { + setCreateDialogKnownToken(undefined); + setCreateDialog(true); + }}> Create Application } @@ -137,6 +143,7 @@ const Applications = observer(() => { setToRekeyApp(app)} fUpload={() => handleImageUploadClick(app.id)} fDeleteImage={() => setToDeleteImage(app)} fDelete={() => setToDeleteApp(app)} @@ -158,6 +165,7 @@ const Applications = observer(() => { {createDialog && ( setCreateDialog(false)} fOnSubmit={appStore.create} /> @@ -173,6 +181,29 @@ const Applications = observer(() => { initialDefaultPriority={toUpdateApp?.defaultPriority} /> )} + {toRekeyApp != null && ( + setToRekeyApp(undefined)} + fOnSubmit={() => + appStore + .rekey(toRekeyApp.id) + .then((token) => { + setCreateDialogKnownToken(token); + setCreateDialog(true); + }) + .catch((error) => { + snackManager.snack(error.message); + }) + } + requireElevated + /> + )} {toDeleteApp != null && ( { interface IRowProps { app: IApplication; + fRekey?: VoidFunction; fUpload: VoidFunction; fDeleteImage: VoidFunction; fDelete: VoidFunction; fEdit: VoidFunction; } -const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { +const Row = ({app, fRekey, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { const {classes} = useStyles(); const isDefaultImage = app.image === 'static/defaultapp.png'; @@ -259,16 +291,27 @@ const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { {formatDate(app.createdAt)} - - - - - - - - - - + {fRekey && ( + + + + + + )} + {fEdit && ( + + + + + + )} + {fDelete && ( + + + + + + )} ); }; diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index 92b556af..5c4f2360 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -20,8 +20,9 @@ enum Col { DefaultPriority = 5, LastUsed = 6, Created = 7, - EditUpdate = 8, - EditDelete = 9, + EditRekey = 8, + EditUpdate = 9, + EditDelete = 10, } const $table = selector.table('#app-table'); @@ -105,6 +106,19 @@ describe('Application', () => { await waitforApp('desktop', 'kitchen_computer', 2)(); await waitforApp('raspberry_pi', 'home_pi', 3)(); }); + describe('security updates', () => { + it('regenerates application token', async () => { + await page.click($table.cell(1, Col.EditRekey, '.rekey')); + await page.waitForSelector(selector.$confirmDialog.selector()); + await page.click(selector.$confirmDialog.button('.confirm')); + await page.waitForSelector($dialog.selector()); + const token = await innerText(page, $dialog.p('.token')); + expect(token.startsWith('gtfya.')).toBeTruthy(); + await page.waitForSelector($dialog.button('.finish')); + await page.click($dialog.button('.finish')); + await waitToDisappear(page, $dialog.selector()); + }); + }); it('deletes application', async () => { await page.click($table.cell(2, Col.EditDelete, '.delete')); From e7c55fdd92d55bb19cd4e30f0a3dbf69845c557e Mon Sep 17 00:00:00 2001 From: Yumechi Date: Tue, 30 Jun 2026 19:49:24 +0800 Subject: [PATCH 2/5] fixup: lift token dialog --- ui/src/application/AddApplicationDialog.tsx | 145 +++++++------------- ui/src/application/AppStore.ts | 5 +- ui/src/application/Applications.tsx | 77 +++++------ ui/src/client/AddClientDialog.tsx | 118 +++++----------- ui/src/client/Clients.tsx | 10 +- ui/src/common/TokenConfirmDialog.tsx | 54 ++++++++ ui/src/tests/application.test.ts | 27 ++-- ui/src/tests/client.test.ts | 12 +- 8 files changed, 212 insertions(+), 236 deletions(-) create mode 100644 ui/src/common/TokenConfirmDialog.tsx diff --git a/ui/src/application/AddApplicationDialog.tsx b/ui/src/application/AddApplicationDialog.tsx index 53d69b3c..a3c197db 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -8,119 +8,74 @@ import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; import React, {useState} from 'react'; -import {Typography} from '@mui/material'; -import {copyToClipboard} from '../clipboard'; -import {useStores} from '../stores'; interface IProps { - fKnownToken?: string; - fClose: VoidFunction; + fClose: (token: string | null) => void; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; } -export const AddApplicationDialog = ({fClose, fOnSubmit, fKnownToken}: IProps) => { - const [returnToken, setReturnToken] = useState(fKnownToken || ''); +export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [defaultPriority, setDefaultPriority] = useState(0); - const {snackManager} = useStores(); const submitEnabled = name.length !== 0; const submitAndNext = async () => { const token = await fOnSubmit(name, description, defaultPriority); - setReturnToken(token); + fClose(token); }; return ( - - - {fKnownToken ? 'Update an application' : 'Create an application'} - + fClose(null)} + aria-labelledby="form-dialog-title" + id="app-dialog"> + Create an application - {returnToken ? ( - <> - Your token will only be shown once. - - { - window.getSelection()?.selectAllChildren(e.currentTarget); - }}> - - {returnToken} - - - - ) : ( - <> - - An application is allowed to send messages. - - setName(e.target.value)} - fullWidth - /> - setDescription(e.target.value)} - fullWidth - multiline - /> - setDefaultPriority(value)} - fullWidth - /> - - )} + An application is allowed to send messages. + setName(e.target.value)} + fullWidth + /> + setDescription(e.target.value)} + fullWidth + multiline + /> + setDefaultPriority(value)} + fullWidth + /> - {returnToken ? ( - - ) : ( - - )} - {returnToken ? ( - - ) : ( - -
- -
-
- )} + + +
+ +
+
); diff --git a/ui/src/application/AppStore.ts b/ui/src/application/AppStore.ts index 7a805f69..a6fa32ba 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -36,10 +36,13 @@ export class AppStore extends BaseStore { this.snack('Application image updated'); }; - public async rekey(id: number): Promise { + public async regenerateToken(id: number): Promise { const response = await axios.put(`${config.get('url')}application/${id}/security`, { regenerateToken: true, }); + if (!response.data?.regenerateToken?.token) { + throw new Error('unexpected response from server'); + } return response.data.regenerateToken.token; } diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index b9e7a973..81d90314 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -37,6 +37,7 @@ import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; import {makeStyles} from 'tss-react/mui'; import {ButtonBase, Tooltip} from '@mui/material'; +import {TokenConfirmDialog} from '../common/TokenConfirmDialog'; const useStyles = makeStyles()((theme) => ({ imageContainer: { @@ -60,14 +61,14 @@ const useStyles = makeStyles()((theme) => ({ })); const Applications = observer(() => { - const {appStore, snackManager} = useStores(); + const {appStore} = useStores(); const apps = appStore.getItems(); const [toDeleteApp, setToDeleteApp] = useState(); const [toDeleteImage, setToDeleteImage] = useState(); const [toUpdateApp, setToUpdateApp] = useState(); - const [toRekeyApp, setToRekeyApp] = useState(); + const [toRegenerateTokenApp, setToRegenerateTokenApp] = useState(); + const [toShowToken, setToShowToken] = useState(''); const [createDialog, setCreateDialog] = useState(false); - const [createDialogKnownToken, setCreateDialogKnownToken] = useState(); const fileInputRef = useRef(null); const uploadId = useRef(-1); @@ -110,7 +111,6 @@ const Applications = observer(() => { variant="contained" color="primary" onClick={() => { - setCreateDialogKnownToken(undefined); setCreateDialog(true); }}> Create Application @@ -143,7 +143,7 @@ const Applications = observer(() => { setToRekeyApp(app)} + fRegenerateToken={() => setToRegenerateTokenApp(app)} fUpload={() => handleImageUploadClick(app.id)} fDeleteImage={() => setToDeleteImage(app)} fDelete={() => setToDeleteApp(app)} @@ -163,10 +163,15 @@ const Applications = observer(() => { /> + {toShowToken && ( + setToShowToken('')} /> + )} {createDialog && ( setCreateDialog(false)} + fClose={(token) => { + setCreateDialog(false); + setToShowToken(token || ''); + }} fOnSubmit={appStore.create} /> )} @@ -181,25 +186,19 @@ const Applications = observer(() => { initialDefaultPriority={toUpdateApp?.defaultPriority} /> )} - {toRekeyApp != null && ( + {toRegenerateTokenApp != null && ( setToRekeyApp(undefined)} + fClose={() => setToRegenerateTokenApp(undefined)} fOnSubmit={() => - appStore - .rekey(toRekeyApp.id) - .then((token) => { - setCreateDialogKnownToken(token); - setCreateDialog(true); - }) - .catch((error) => { - snackManager.snack(error.message); - }) + appStore.regenerateToken(toRegenerateTokenApp.id).then((token) => { + setToShowToken(token); + }) } requireElevated /> @@ -227,14 +226,14 @@ const Applications = observer(() => { interface IRowProps { app: IApplication; - fRekey?: VoidFunction; + fRegenerateToken: VoidFunction; fUpload: VoidFunction; fDeleteImage: VoidFunction; fDelete: VoidFunction; fEdit: VoidFunction; } -const Row = ({app, fRekey, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { +const Row = ({app, fRegenerateToken, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { const {classes} = useStyles(); const isDefaultImage = app.image === 'static/defaultapp.png'; @@ -291,27 +290,21 @@ const Row = ({app, fRekey, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => {formatDate(app.createdAt)} - {fRekey && ( - - - - - - )} - {fEdit && ( - - - - - - )} - {fDelete && ( - - - - - - )} + + + + + + + + + + + + + + + ); }; diff --git a/ui/src/client/AddClientDialog.tsx b/ui/src/client/AddClientDialog.tsx index 041bc96d..cea0a123 100644 --- a/ui/src/client/AddClientDialog.tsx +++ b/ui/src/client/AddClientDialog.tsx @@ -7,104 +7,62 @@ import DialogTitle from '@mui/material/DialogTitle'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {NumberField} from '../common/NumberField'; -import {DialogContentText, Typography} from '@mui/material'; -import {copyToClipboard} from '../clipboard'; -import {useStores} from '../stores'; interface IProps { - fClose: VoidFunction; + fClose: (token: string | null) => void; fOnSubmit: (name: string, expiresAfterInactivitySeconds: number) => Promise; } const AddClientDialog = ({fClose, fOnSubmit}: IProps) => { - const [returnToken, setReturnToken] = useState(''); const [name, setName] = useState(''); const [expiresAfter, setExpiresAfter] = useState(0); - const {snackManager} = useStores(); const submitEnabled = name.length !== 0; const submitAndNext = async () => { const token = await fOnSubmit(name, Math.max(0, expiresAfter)); - setReturnToken(token); + fClose(token); }; return ( - + fClose(null)} + aria-labelledby="form-dialog-title" + id="client-dialog"> Create a client - {returnToken ? ( - <> - Your token will only be shown once. - - { - window.getSelection()?.selectAllChildren(e.currentTarget); - }}> - - {returnToken} - - - - ) : ( - <> - setName(e.target.value)} - fullWidth - /> - setExpiresAfter(value)} - fullWidth - /> - - )} + setName(e.target.value)} + fullWidth + /> + setExpiresAfter(value)} + fullWidth + /> - {returnToken ? ( - - ) : ( - - )} - {returnToken ? ( - - ) : ( - -
- -
-
- )} + + +
+ +
+
); diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 0185768a..67e991d8 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -23,6 +23,7 @@ import {formatDate} from '../common/TimeAgoFormatter'; import {RemainingTime} from '../common/RemainingTime'; import {observer} from 'mobx-react-lite'; import {useStores} from '../stores'; +import {TokenConfirmDialog} from '../common/TokenConfirmDialog'; const Clients = observer(() => { const {clientStore} = useStores(); @@ -30,6 +31,7 @@ const Clients = observer(() => { const [toUpdateClient, setToUpdateClient] = useState(); const [toElevateClient, setToElevateClient] = useState(); const [createDialog, setCreateDialog] = useState(false); + const [toShowToken, setToShowToken] = useState(''); const clients = clientStore.getItems(); useEffect(() => void clientStore.refresh(), []); @@ -80,9 +82,15 @@ const Clients = observer(() => { + {toShowToken && ( + setToShowToken('')} /> + )} {createDialog && ( setCreateDialog(false)} + fClose={(token) => { + setCreateDialog(false); + setToShowToken(token || ''); + }} fOnSubmit={clientStore.create} /> )} diff --git a/ui/src/common/TokenConfirmDialog.tsx b/ui/src/common/TokenConfirmDialog.tsx new file mode 100644 index 00000000..2e874b8a --- /dev/null +++ b/ui/src/common/TokenConfirmDialog.tsx @@ -0,0 +1,54 @@ +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import React from 'react'; +import {Typography} from '@mui/material'; +import {useStores} from '../stores'; +import {copyToClipboard} from '../clipboard'; + +interface IProps { + token: string; + fClose: VoidFunction; +} + +export const TokenConfirmDialog = ({token, fClose}: IProps) => { + const {snackManager} = useStores(); + + return ( + + Token Confirmation + + Your token will only be shown once. + { + window.getSelection()?.selectAllChildren(e.currentTarget); + }}> + + {token} + + + + + + + + + ); +}; diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index 5c4f2360..722490bc 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -20,13 +20,14 @@ enum Col { DefaultPriority = 5, LastUsed = 6, Created = 7, - EditRekey = 8, + EditRegenerateToken = 8, EditUpdate = 9, EditDelete = 10, } const $table = selector.table('#app-table'); const $dialog = selector.form('#app-dialog'); +const $tokenDialog = selector.form('#token-dialog'); const waitforApp = (name: string, description: string, row: number): (() => Promise) => @@ -62,12 +63,13 @@ const createApp = await page.type($dialog.input('.name'), name); await page.type($dialog.textarea('.description'), description); await page.click($dialog.button('.create')); - await page.waitForSelector($dialog.button('.finish')); - await page.waitForSelector($dialog.p('.token')); - const token = await innerText(page, $dialog.p('.token')); - expect(token.startsWith('gtfya.')).toBeTruthy(); - await page.click($dialog.button('.finish')); await waitToDisappear(page, $dialog.selector()); + await page.waitForSelector($tokenDialog.p('.token')); + const token = await innerText(page, $tokenDialog.p('.token')); + console.log('token', token); + expect(token.startsWith('gtfya.')).toBeTruthy(); + await page.click($tokenDialog.button('.finish')); + await waitToDisappear(page, $tokenDialog.selector()); }; describe('Application', () => { @@ -108,15 +110,16 @@ describe('Application', () => { }); describe('security updates', () => { it('regenerates application token', async () => { - await page.click($table.cell(1, Col.EditRekey, '.rekey')); + await page.click($table.cell(1, Col.EditRegenerateToken, '.regenerate-token')); await page.waitForSelector(selector.$confirmDialog.selector()); await page.click(selector.$confirmDialog.button('.confirm')); - await page.waitForSelector($dialog.selector()); - const token = await innerText(page, $dialog.p('.token')); + await waitToDisappear(page, selector.$confirmDialog.selector()); + await page.waitForSelector($tokenDialog.p('.token')); + const token = await innerText(page, $tokenDialog.p('.token')); expect(token.startsWith('gtfya.')).toBeTruthy(); - await page.waitForSelector($dialog.button('.finish')); - await page.click($dialog.button('.finish')); - await waitToDisappear(page, $dialog.selector()); + await page.waitForSelector($tokenDialog.button('.finish')); + await page.click($tokenDialog.button('.finish')); + await waitToDisappear(page, $tokenDialog.selector()); }); }); it('deletes application', async () => { diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts index e690fb99..1c8103ec 100644 --- a/ui/src/tests/client.test.ts +++ b/ui/src/tests/client.test.ts @@ -47,14 +47,15 @@ const fillClientDialog = await page.type(expiresSelector, data.expiresAfter.toString()); } await page.click($dialog.button(submit)); + await waitToDisappear(page, $dialog.selector()); if (hasToken) { - await page.waitForSelector($dialog.p('.token')); - const token = await innerText(page, $dialog.p('.token')); + await page.waitForSelector($tokenDialog.p('.token')); + const token = await innerText(page, $tokenDialog.p('.token')); expect(token.startsWith('gtfyc.')).toBeTruthy(); - await page.waitForSelector($dialog.button('.finish')); - await page.click($dialog.button('.finish')); + await page.waitForSelector($tokenDialog.button('.finish')); + await page.click($tokenDialog.button('.finish')); + await waitToDisappear(page, $tokenDialog.selector()); } - await waitToDisappear(page, $dialog.selector()); }; const createClient = (data: ClientFields) => @@ -65,6 +66,7 @@ const updateClient = (id: number, data: ClientFields) => const $table = selector.table('#client-table'); const $dialog = selector.form('#client-dialog'); +const $tokenDialog = selector.form('#token-dialog'); describe('Client', () => { it('does login', async () => await auth.login(page)); From 52e2b6d7649be47e85d24714cfa5bf7071716bb1 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Tue, 30 Jun 2026 19:52:27 +0800 Subject: [PATCH 3/5] fixup: go testify style --- api/application_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/application_test.go b/api/application_test.go index 94fe8907..238c8314 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -155,9 +155,9 @@ func (s *ApplicationSuite) Test_UpdateApplicationSecurity_regenerateToken() { s.a.UpdateApplicationSecurity(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) bodyBytes, err := io.ReadAll(s.recorder.Body) - assert.Nil(s.T(), err) + assert.NoError(s.T(), err) var got model.SecurityUpdateActionResponse - assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + assert.NoError(s.T(), json.Unmarshal(bodyBytes, &got)) assert.Equal(s.T(), &model.SecurityUpdateActionResponse{ RegenerateToken: &model.RegenerateTokenResponse{ Token: got.RegenerateToken.Token, @@ -180,7 +180,7 @@ func (s *ApplicationSuite) Test_UpdateApplicationSecurity_isNoOpIfNilAction() { s.a.UpdateApplicationSecurity(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) bodyBytes, err := io.ReadAll(s.recorder.Body) - assert.Nil(s.T(), err) + assert.NoError(s.T(), err) assert.Equal(s.T(), "{}", string(bodyBytes)) newToken, err := s.db.GetApplicationByID(1) assert.NoError(s.T(), err) From 13ebe86724e3cc8bbdd21e02b3acabcd69bbff71 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Tue, 30 Jun 2026 20:12:35 +0800 Subject: [PATCH 4/5] fixup: fix elevation test --- ui/src/tests/elevation.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/tests/elevation.test.ts b/ui/src/tests/elevation.test.ts index 993708f2..81acc4fb 100644 --- a/ui/src/tests/elevation.test.ts +++ b/ui/src/tests/elevation.test.ts @@ -16,6 +16,7 @@ afterAll(async () => await gotify.close()); const $clientTable = selector.table('#client-table'); const $clientDialog = selector.form('#client-dialog'); +const $tokenDialog = selector.form('#token-dialog'); // This expects the session to be already elevated. const cancelElevationViaUI = async (row: number) => { @@ -53,9 +54,10 @@ describe('Elevation', () => { await page.waitForSelector($clientDialog.selector()); await page.type($clientDialog.input('.name'), 'test-client'); await page.click($clientDialog.button('.create')); - await page.waitForSelector($clientDialog.button('.finish')); - await page.click($clientDialog.button('.finish')); await waitToDisappear(page, $clientDialog.selector()); + await page.waitForSelector($tokenDialog.button('.finish')); + await page.click($tokenDialog.button('.finish')); + await waitToDisappear(page, $tokenDialog.selector()); await page.waitForSelector($clientTable.row(2)); expect(await count(page, $clientTable.rows())).toBe(2); }); From 2dfdee284a7d119e3b9042c644b10d2d297dbe9f Mon Sep 17 00:00:00 2001 From: Yumechi Date: Wed, 1 Jul 2026 16:25:45 +0800 Subject: [PATCH 5/5] apply suggestions --- ui/src/application/Applications.tsx | 2 +- ui/src/client/Clients.tsx | 2 +- ui/src/tests/application.test.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index 81d90314..048edc39 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -170,7 +170,7 @@ const Applications = observer(() => { { setCreateDialog(false); - setToShowToken(token || ''); + setToShowToken(token ?? ''); }} fOnSubmit={appStore.create} /> diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 67e991d8..2f0e9639 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -89,7 +89,7 @@ const Clients = observer(() => { { setCreateDialog(false); - setToShowToken(token || ''); + setToShowToken(token ?? ''); }} fOnSubmit={clientStore.create} /> diff --git a/ui/src/tests/application.test.ts b/ui/src/tests/application.test.ts index 722490bc..71ff65ac 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -66,7 +66,6 @@ const createApp = await waitToDisappear(page, $dialog.selector()); await page.waitForSelector($tokenDialog.p('.token')); const token = await innerText(page, $tokenDialog.p('.token')); - console.log('token', token); expect(token.startsWith('gtfya.')).toBeTruthy(); await page.click($tokenDialog.button('.finish')); await waitToDisappear(page, $tokenDialog.selector());