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..238c8314 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.NoError(s.T(), err) + var got model.SecurityUpdateActionResponse + assert.NoError(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.NoError(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..a3c197db 100644 --- a/ui/src/application/AddApplicationDialog.tsx +++ b/ui/src/application/AddApplicationDialog.tsx @@ -8,116 +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 { - fClose: VoidFunction; + fClose: (token: string | null) => void; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise; } export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => { - const [returnToken, setReturnToken] = useState(''); 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 ( - + 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 9fbb5586..a6fa32ba 100644 --- a/ui/src/application/AppStore.ts +++ b/ui/src/application/AppStore.ts @@ -36,6 +36,16 @@ export class AppStore extends BaseStore { this.snack('Application image updated'); }; + 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; + } + 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..048edc39 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'; @@ -36,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: { @@ -64,6 +66,8 @@ const Applications = observer(() => { const [toDeleteApp, setToDeleteApp] = useState(); const [toDeleteImage, setToDeleteImage] = useState(); const [toUpdateApp, setToUpdateApp] = useState(); + const [toRegenerateTokenApp, setToRegenerateTokenApp] = useState(); + const [toShowToken, setToShowToken] = useState(''); const [createDialog, setCreateDialog] = useState(false); const fileInputRef = useRef(null); @@ -106,7 +110,9 @@ const Applications = observer(() => { id="create-app" variant="contained" color="primary" - onClick={() => setCreateDialog(true)}> + onClick={() => { + setCreateDialog(true); + }}> Create Application } @@ -137,6 +143,7 @@ const Applications = observer(() => { setToRegenerateTokenApp(app)} fUpload={() => handleImageUploadClick(app.id)} fDeleteImage={() => setToDeleteImage(app)} fDelete={() => setToDeleteApp(app)} @@ -156,9 +163,15 @@ const Applications = observer(() => { /> + {toShowToken && ( + setToShowToken('')} /> + )} {createDialog && ( setCreateDialog(false)} + fClose={(token) => { + setCreateDialog(false); + setToShowToken(token ?? ''); + }} fOnSubmit={appStore.create} /> )} @@ -173,6 +186,23 @@ const Applications = observer(() => { initialDefaultPriority={toUpdateApp?.defaultPriority} /> )} + {toRegenerateTokenApp != null && ( + setToRegenerateTokenApp(undefined)} + fOnSubmit={() => + appStore.regenerateToken(toRegenerateTokenApp.id).then((token) => { + setToShowToken(token); + }) + } + requireElevated + /> + )} {toDeleteApp != null && ( { interface IRowProps { app: IApplication; + fRegenerateToken: VoidFunction; fUpload: VoidFunction; fDeleteImage: VoidFunction; fDelete: VoidFunction; fEdit: VoidFunction; } -const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { +const Row = ({app, fRegenerateToken, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { const {classes} = useStyles(); const isDefaultImage = app.image === 'static/defaultapp.png'; @@ -259,6 +290,11 @@ const Row = ({app, fDelete, fUpload, fDeleteImage, fEdit}: IRowProps) => { {formatDate(app.createdAt)} + + + + + 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..2f0e9639 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 92b556af..71ff65ac 100644 --- a/ui/src/tests/application.test.ts +++ b/ui/src/tests/application.test.ts @@ -20,12 +20,14 @@ enum Col { DefaultPriority = 5, LastUsed = 6, Created = 7, - EditUpdate = 8, - EditDelete = 9, + 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) => @@ -61,12 +63,12 @@ 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')); + expect(token.startsWith('gtfya.')).toBeTruthy(); + await page.click($tokenDialog.button('.finish')); + await waitToDisappear(page, $tokenDialog.selector()); }; describe('Application', () => { @@ -105,6 +107,20 @@ 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.EditRegenerateToken, '.regenerate-token')); + await page.waitForSelector(selector.$confirmDialog.selector()); + await page.click(selector.$confirmDialog.button('.confirm')); + 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($tokenDialog.button('.finish')); + await page.click($tokenDialog.button('.finish')); + await waitToDisappear(page, $tokenDialog.selector()); + }); + }); it('deletes application', async () => { await page.click($table.cell(2, Col.EditDelete, '.delete')); 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)); 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); });