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.
This commit is contained in:
Maurice
2026-10-09 11:00:21 +02:00
parent feb99ce398
commit 6c5ab5414a
4 changed files with 139 additions and 44 deletions
@@ -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<string, unknown>) => (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);
});
});
@@ -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<boolean> => {
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 };
}
@@ -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 */}
+4 -23
View File
@@ -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 \\
</DialogButton>
<button
type="button"
onClick={async () => {
setRotatingJwt(true);
try {
await adminApi.rotateJwtSecret();
setShowRotateJwtModal(false);
logout();
navigate('/login', { state: { noRedirect: true } });
} catch {
toast.error(t('common.error'));
setRotatingJwt(false);
}
}}
onClick={rotateJwt}
disabled={rotatingJwt}
className={DANGER_FILL}
style={fs(13, 'body')}
@@ -447,12 +433,7 @@ docker run -d --name trek \\
onClose={() => setPasskeyResetUser(null)}
onConfirm={async () => {
if (!passkeyResetUser) return;
try {
const r = await adminApi.resetUserPasskeys(passkeyResetUser.id);
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }));
} catch {
toast.error(t('common.error'));
}
await resetPasskeys(passkeyResetUser);
}}
title={t('admin.passkey.reset')}
message={passkeyResetUser ? t('admin.passkey.resetConfirm', { name: passkeyResetUser.username }) : ''}