From 6c5ab5414a928a0a62da706e2623c26f7e32ced9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 11:00:21 +0200 Subject: [PATCH] refactor(admin): share the JWT rotation and passkey reset between desktop and phone useAdminUserActions now rotates the JWT secret and resets a user's passkeys for the desktop dialogs and the phone sheets. Each shell keeps its own confirm step, and the phone sheet still stays busy until the reset settles. --- .../Admin/useAdminUserActions.test.ts | 81 +++++++++++++++++++ .../components/Admin/useAdminUserActions.ts | 46 +++++++++++ .../src/mobile/screens/admin/MAdminSheets.tsx | 29 ++----- client/src/pages/admin/AdminUserModals.tsx | 27 +------ 4 files changed, 139 insertions(+), 44 deletions(-) create mode 100644 client/src/components/Admin/useAdminUserActions.test.ts create mode 100644 client/src/components/Admin/useAdminUserActions.ts diff --git a/client/src/components/Admin/useAdminUserActions.test.ts b/client/src/components/Admin/useAdminUserActions.test.ts new file mode 100644 index 000000000..075d6f4af --- /dev/null +++ b/client/src/components/Admin/useAdminUserActions.test.ts @@ -0,0 +1,81 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAdminUserActions } from './useAdminUserActions'; + +// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-005 + +const api = vi.hoisted(() => ({ rotateJwtSecret: vi.fn(), resetUserPasskeys: vi.fn() })); +vi.mock('../../api/client', () => ({ adminApi: api })); + +const t = (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key); + +function host() { + return { + toast: { success: vi.fn(), error: vi.fn() }, + setRotatingJwt: vi.fn(), + setShowRotateJwtModal: vi.fn(), + logout: vi.fn(), + navigate: vi.fn(), + }; +} + +function mount(admin = host()) { + return { admin, actions: renderHook(() => useAdminUserActions(admin, t)).result.current }; +} + +beforeEach(() => { + api.rotateJwtSecret.mockReset().mockResolvedValue({}); + api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 }); +}); + +describe('useAdminUserActions', () => { + it('FE-HOOK-ADMUSERACT-001: rotating the secret closes the dialog, logs out and goes to the login page', async () => { + const { admin, actions } = mount(); + await act(() => actions.rotateJwt()); + expect(admin.setRotatingJwt).toHaveBeenCalledWith(true); + expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false); + expect(admin.logout).toHaveBeenCalledTimes(1); + expect(admin.navigate).toHaveBeenCalledWith('/login', { state: { noRedirect: true } }); + expect(admin.logout.mock.invocationCallOrder[0]).toBeLessThan(admin.navigate.mock.invocationCallOrder[0]); + }); + + it('FE-HOOK-ADMUSERACT-002: a failed rotation toasts, stays signed in and clears the busy state', async () => { + api.rotateJwtSecret.mockRejectedValue(new Error('x')); + const { admin, actions } = mount(); + await act(() => actions.rotateJwt()); + expect(admin.toast.error).toHaveBeenCalledWith('common.error'); + expect(admin.setRotatingJwt.mock.calls).toEqual([[true], [false]]); + expect(admin.logout).not.toHaveBeenCalled(); + expect(admin.setShowRotateJwtModal).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-ADMUSERACT-003: a passkey reset toasts the count and reports success', async () => { + const { admin, actions } = mount(); + let ok: boolean | undefined; + await act(async () => { + ok = await actions.resetPasskeys({ id: 7 }); + }); + expect(api.resetUserPasskeys).toHaveBeenCalledWith(7); + expect(admin.toast.success).toHaveBeenCalledWith('admin.passkey.resetDone {"count":2}'); + expect(ok).toBe(true); + }); + + it('FE-HOOK-ADMUSERACT-004: an answer without a count reads as zero', async () => { + api.resetUserPasskeys.mockResolvedValue({}); + const { admin, actions } = mount(); + await act(() => actions.resetPasskeys({ id: 7 })); + expect(admin.toast.success).toHaveBeenCalledWith('admin.passkey.resetDone {"count":0}'); + }); + + it('FE-HOOK-ADMUSERACT-005: a failed passkey reset toasts and reports failure', async () => { + api.resetUserPasskeys.mockRejectedValue(new Error('x')); + const { admin, actions } = mount(); + let ok: boolean | undefined; + await act(async () => { + ok = await actions.resetPasskeys({ id: 7 }); + }); + expect(admin.toast.error).toHaveBeenCalledWith('common.error'); + expect(ok).toBe(false); + }); +}); diff --git a/client/src/components/Admin/useAdminUserActions.ts b/client/src/components/Admin/useAdminUserActions.ts new file mode 100644 index 000000000..e7538c8b9 --- /dev/null +++ b/client/src/components/Admin/useAdminUserActions.ts @@ -0,0 +1,46 @@ +import { adminApi } from '../../api/client'; +import type { TranslationFn } from '../../types'; + +interface UserActionsHost { + toast: { success: (message: string) => void; error: (message: string) => void }; + setRotatingJwt: (value: boolean) => void; + setShowRotateJwtModal: (value: boolean) => void; + logout: () => void; + navigate: (to: string, options?: { state?: unknown }) => void; +} + +/** + * The two irreversible user actions behind the admin dialogs and their phone sheets: + * rotating the JWT secret, which signs everyone out including the admin, and removing a + * user's passkeys. Each shell keeps its own confirm step and busy state around them. + */ +export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) { + const { toast, setRotatingJwt, setShowRotateJwtModal, logout, navigate } = admin; + + const rotateJwt = async () => { + setRotatingJwt(true); + try { + await adminApi.rotateJwtSecret(); + setShowRotateJwtModal(false); + logout(); + navigate('/login', { state: { noRedirect: true } }); + } catch { + toast.error(t('common.error')); + setRotatingJwt(false); + } + }; + + /** Removes the user's passkeys and toasts how many went; true when it worked. */ + const resetPasskeys = async (user: { id: number }): Promise => { + try { + const r = await adminApi.resetUserPasskeys(user.id); + toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 })); + return true; + } catch { + toast.error(t('common.error')); + return false; + } + }; + + return { rotateJwt, resetPasskeys }; +} diff --git a/client/src/mobile/screens/admin/MAdminSheets.tsx b/client/src/mobile/screens/admin/MAdminSheets.tsx index 10460d7d4..dc48e1d71 100644 --- a/client/src/mobile/screens/admin/MAdminSheets.tsx +++ b/client/src/mobile/screens/admin/MAdminSheets.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, Trash2 } from 'lucide-react' -import { adminApi } from '../../../api/client' +import { useAdminUserActions } from '../../../components/Admin/useAdminUserActions' import type { TranslationFn } from '../../../types' import type { useAdmin } from '../../../pages/admin/useAdmin' import MSheet from '../../components/MSheet' @@ -25,11 +25,11 @@ docker run -d --name trek \\ // and delete), the "how to update" instructions and the rotate-JWT confirm. export default function MAdminSheets({ admin, t }: MAdminSheetsProps) { const { - logout, navigate, toast, currentUser, + currentUser, editingUser, setEditingUser, editForm, setEditForm, showCreateUser, setShowCreateUser, createForm, setCreateForm, updateInfo, showUpdateModal, setShowUpdateModal, - showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt, + showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, handleCreateUser, handleSaveUser, handleDeleteUser, } = admin @@ -41,33 +41,20 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) { const [showResetPasskeys, setShowResetPasskeys] = useState(false) const [resettingPk, setResettingPk] = useState(false) + const userActions = useAdminUserActions(admin, t) + const { rotateJwt } = userActions + + // The sheet stays open and busy while the reset runs and closes once it worked. const resetPasskeys = async () => { if (!editingUser) return setResettingPk(true) try { - const r = await adminApi.resetUserPasskeys(editingUser.id) - toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 })) - setShowResetPasskeys(false) - } catch { - toast.error(t('common.error')) + if (await userActions.resetPasskeys(editingUser)) setShowResetPasskeys(false) } finally { setResettingPk(false) } } - const rotateJwt = async () => { - setRotatingJwt(true) - try { - await adminApi.rotateJwtSecret() - setShowRotateJwtModal(false) - logout() - navigate('/login', { state: { noRedirect: true } }) - } catch { - toast.error(t('common.error')) - setRotatingJwt(false) - } - } - return ( <> {/* Create user */} diff --git a/client/src/pages/admin/AdminUserModals.tsx b/client/src/pages/admin/AdminUserModals.tsx index 61ca8ff05..e37e7aaa4 100644 --- a/client/src/pages/admin/AdminUserModals.tsx +++ b/client/src/pages/admin/AdminUserModals.tsx @@ -11,7 +11,7 @@ import { UserPlus, } from 'lucide-react'; import React, { useId } from 'react'; -import { adminApi } from '../../api/client'; +import { useAdminUserActions } from '../../components/Admin/useAdminUserActions'; import CustomSelect from '../../components/shared/CustomSelect'; import ConfirmDialog from '../../components/shared/ConfirmDialog'; import { Tooltip } from '../../components/shared/Tooltip'; @@ -93,9 +93,6 @@ function PasswordInput({ // Pure layout around the useAdmin hook. export default function AdminUserModals({ admin, t }: AdminUserModalsProps): React.ReactElement { const { - logout, - navigate, - toast, editingUser, setEditingUser, editForm, @@ -110,10 +107,10 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, - setRotatingJwt, handleCreateUser, handleSaveUser, } = admin; + const { rotateJwt, resetPasskeys } = useAdminUserActions(admin, t); const [showCreatePw, setShowCreatePw] = React.useState(false); const [showEditPw, setShowEditPw] = React.useState(false); // The user whose passkeys wait for the admin's answer in the confirm dialog. @@ -405,18 +402,7 @@ docker run -d --name trek \\