From 7da3a1724c0df3ca64387472ef87a9597edf9b6b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:13:35 +0200 Subject: [PATCH] refactor(admin): share the MCP token and OAuth session admin between desktop and phone useMcpTokensAdmin now loads both lists, revokes sessions, deletes tokens and keeps the expanded scope chips. Both panels keep their own cards and confirm dialogs. --- .../components/Admin/AdminMcpTokensPanel.tsx | 82 +--------- .../Admin/useMcpTokensAdmin.test.ts | 140 ++++++++++++++++++ .../src/components/Admin/useMcpTokensAdmin.ts | 111 ++++++++++++++ .../screens/admin/MAdminMcpTokensPanel.tsx | 88 +---------- 4 files changed, 263 insertions(+), 158 deletions(-) create mode 100644 client/src/components/Admin/useMcpTokensAdmin.test.ts create mode 100644 client/src/components/Admin/useMcpTokensAdmin.ts diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index 73c84e1c8..483ce9242 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -1,36 +1,11 @@ -import { useState, useEffect, type ReactNode } from 'react' -import { adminApi } from '../../api/client' -import { useToast } from '../shared/Toast' +import { type ReactNode } from 'react' import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react' import { useTranslation } from '../../i18n' import ConfirmDialog from '../shared/ConfirmDialog' import { Tooltip } from '../shared/Tooltip' import { fs } from '../shared/DialogShell' import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit' - -interface AdminOAuthSession { - id: number - client_id: string - client_name: string - user_id: number - username: string - scopes: string[] - access_token_expires_at: string - refresh_token_expires_at: string - created_at: string -} - -interface AdminMcpToken { - id: number - name: string - token_prefix: string - created_at: string - last_used_at: string | null - user_id: number - username: string -} - -const SCOPES_PREVIEW = 6 +import { SCOPES_PREVIEW, useMcpTokensAdmin } from './useMcpTokensAdmin' const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3' const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary' @@ -85,57 +60,12 @@ function DeleteAction({ label, onClick }: { label: string; onClick: () => void } } export default function AdminMcpTokensPanel() { - const [sessions, setSessions] = useState([]) - const [sessionsLoading, setSessionsLoading] = useState(true) - const [tokens, setTokens] = useState([]) - const [tokensLoading, setTokensLoading] = useState(true) - const [expandedScopes, setExpandedScopes] = useState>(new Set()) - const [revokeConfirmId, setRevokeConfirmId] = useState(null) - const [deleteConfirmId, setDeleteConfirmId] = useState(null) - - const toggleScopes = (id: number) => - setExpandedScopes(prev => { - const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) - return next - }) - const toast = useToast() + const { + sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes, + revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete, + } = useMcpTokensAdmin() const { t, locale } = useTranslation() - useEffect(() => { - adminApi.oauthSessions() - .then(d => setSessions(d.sessions || [])) - .catch(() => toast.error(t('admin.oauthSessions.loadError'))) - .finally(() => setSessionsLoading(false)) - - adminApi.mcpTokens() - .then(d => setTokens(d.tokens || [])) - .catch(() => toast.error(t('admin.mcpTokens.loadError'))) - .finally(() => setTokensLoading(false)) - }, []) - - const handleRevoke = async (id: number) => { - try { - await adminApi.revokeOAuthSession(id) - setSessions(prev => prev.filter(s => s.id !== id)) - setRevokeConfirmId(null) - toast.success(t('admin.oauthSessions.revokeSuccess')) - } catch { - toast.error(t('admin.oauthSessions.revokeError')) - } - } - - const handleDelete = async (id: number) => { - try { - await adminApi.deleteMcpToken(id) - setTokens(prev => prev.filter(tk => tk.id !== id)) - setDeleteConfirmId(null) - toast.success(t('admin.mcpTokens.deleteSuccess')) - } catch { - toast.error(t('admin.mcpTokens.deleteError')) - } - } - const date = (iso: string) => new Date(iso).toLocaleDateString(locale) return ( diff --git a/client/src/components/Admin/useMcpTokensAdmin.test.ts b/client/src/components/Admin/useMcpTokensAdmin.test.ts new file mode 100644 index 000000000..28c4fbbd1 --- /dev/null +++ b/client/src/components/Admin/useMcpTokensAdmin.test.ts @@ -0,0 +1,140 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { adminApi } from '../../api/client'; +import { useMcpTokensAdmin } from './useMcpTokensAdmin'; + +// FE-HOOK-MCPADMIN-001 to FE-HOOK-MCPADMIN-008 + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +const i18n = vi.hoisted(() => ({ prefix: '' })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +// Each render's t keeps the prefix it was made with, like a real locale switch. +vi.mock('../../i18n', () => ({ + useTranslation: () => { + const prefix = i18n.prefix; + return { t: (k: string) => `${prefix}${k}` }; + }, +})); + +const SESSION = { + id: 11, + client_id: 'c', + client_name: 'Claude', + user_id: 1, + username: 'alice', + scopes: ['trips:read'], + access_token_expires_at: '2026-01-01', + refresh_token_expires_at: '2026-02-01', + created_at: '2025-01-01', +}; +const TOKEN = { + id: 21, + name: 'cli', + token_prefix: 'trek_ab', + created_at: '2025-01-01', + last_used_at: null, + user_id: 1, + username: 'alice', +}; + +beforeEach(() => { + i18n.prefix = ''; + toast.success.mockReset(); + toast.error.mockReset(); + vi.spyOn(adminApi, 'oauthSessions').mockResolvedValue({ sessions: [SESSION] }); + vi.spyOn(adminApi, 'mcpTokens').mockResolvedValue({ tokens: [TOKEN] }); + vi.spyOn(adminApi, 'revokeOAuthSession').mockResolvedValue({ success: true }); + vi.spyOn(adminApi, 'deleteMcpToken').mockResolvedValue({ success: true }); +}); +afterEach(() => vi.restoreAllMocks()); + +async function loaded() { + const hook = renderHook(() => useMcpTokensAdmin()); + await waitFor(() => { + expect(hook.result.current.sessionsLoading).toBe(false); + expect(hook.result.current.tokensLoading).toBe(false); + }); + return hook; +} + +describe('useMcpTokensAdmin', () => { + it('FE-HOOK-MCPADMIN-001: loads sessions and tokens once on mount', async () => { + const { result, rerender } = await loaded(); + rerender(); + expect(adminApi.oauthSessions).toHaveBeenCalledTimes(1); + expect(adminApi.mcpTokens).toHaveBeenCalledTimes(1); + expect(result.current.sessions).toEqual([SESSION]); + expect(result.current.tokens).toEqual([TOKEN]); + }); + + it('FE-HOOK-MCPADMIN-002: missing lists read as empty', async () => { + vi.mocked(adminApi.oauthSessions).mockResolvedValue({}); + vi.mocked(adminApi.mcpTokens).mockResolvedValue({}); + const { result } = await loaded(); + expect(result.current.sessions).toEqual([]); + expect(result.current.tokens).toEqual([]); + }); + + it('FE-HOOK-MCPADMIN-003: each failed list toasts its own error, in the locale current when it fails', async () => { + let failSessions!: (e: unknown) => void; + vi.mocked(adminApi.oauthSessions).mockReturnValue(new Promise((_, reject) => (failSessions = reject))); + vi.mocked(adminApi.mcpTokens).mockRejectedValue(new Error('down')); + const { result, rerender } = renderHook(() => useMcpTokensAdmin()); + await waitFor(() => expect(result.current.tokensLoading).toBe(false)); + expect(toast.error).toHaveBeenCalledWith('admin.mcpTokens.loadError'); + i18n.prefix = 'de:'; + rerender(); + await act(async () => failSessions(new Error('down'))); + await waitFor(() => expect(result.current.sessionsLoading).toBe(false)); + expect(toast.error).toHaveBeenCalledWith('de:admin.oauthSessions.loadError'); + }); + + it('FE-HOOK-MCPADMIN-004: toggling scopes expands and collapses one session', async () => { + const { result } = await loaded(); + act(() => result.current.toggleScopes(11)); + expect(result.current.expandedScopes.has(11)).toBe(true); + act(() => result.current.toggleScopes(11)); + expect(result.current.expandedScopes.has(11)).toBe(false); + }); + + it('FE-HOOK-MCPADMIN-005: revoke drops the session, closes the confirm and toasts', async () => { + const { result } = await loaded(); + act(() => result.current.setRevokeConfirmId(11)); + await act(() => result.current.handleRevoke(11)); + expect(adminApi.revokeOAuthSession).toHaveBeenCalledWith(11); + expect(result.current.sessions).toEqual([]); + expect(result.current.revokeConfirmId).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('admin.oauthSessions.revokeSuccess'); + }); + + it('FE-HOOK-MCPADMIN-006: a failed revoke keeps the session and the confirm open', async () => { + vi.mocked(adminApi.revokeOAuthSession).mockRejectedValue(new Error('nope')); + const { result } = await loaded(); + act(() => result.current.setRevokeConfirmId(11)); + await act(() => result.current.handleRevoke(11)); + expect(result.current.sessions).toEqual([SESSION]); + expect(result.current.revokeConfirmId).toBe(11); + expect(toast.error).toHaveBeenCalledWith('admin.oauthSessions.revokeError'); + }); + + it('FE-HOOK-MCPADMIN-007: delete drops the token, closes the confirm and toasts', async () => { + const { result } = await loaded(); + act(() => result.current.setDeleteConfirmId(21)); + await act(() => result.current.handleDelete(21)); + expect(adminApi.deleteMcpToken).toHaveBeenCalledWith(21); + expect(result.current.tokens).toEqual([]); + expect(result.current.deleteConfirmId).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('admin.mcpTokens.deleteSuccess'); + }); + + it('FE-HOOK-MCPADMIN-008: a failed delete keeps the token and the confirm open', async () => { + vi.mocked(adminApi.deleteMcpToken).mockRejectedValue(new Error('nope')); + const { result } = await loaded(); + act(() => result.current.setDeleteConfirmId(21)); + await act(() => result.current.handleDelete(21)); + expect(result.current.tokens).toEqual([TOKEN]); + expect(result.current.deleteConfirmId).toBe(21); + expect(toast.error).toHaveBeenCalledWith('admin.mcpTokens.deleteError'); + }); +}); diff --git a/client/src/components/Admin/useMcpTokensAdmin.ts b/client/src/components/Admin/useMcpTokensAdmin.ts new file mode 100644 index 000000000..2f1b76a21 --- /dev/null +++ b/client/src/components/Admin/useMcpTokensAdmin.ts @@ -0,0 +1,111 @@ +import { useEffect, useEffectEvent, useState } from 'react'; + +import { adminApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useToast } from '../shared/Toast'; + +interface AdminOAuthSession { + id: number; + client_id: string; + client_name: string; + user_id: number; + username: string; + scopes: string[]; + access_token_expires_at: string; + refresh_token_expires_at: string; + created_at: string; +} + +export interface AdminMcpToken { + id: number; + name: string; + token_prefix: string; + created_at: string; + last_used_at: string | null; + user_id: number; + username: string; +} + +/** How many scope chips a session shows before the "more" toggle. */ +export const SCOPES_PREVIEW = 6; + +/** + * The OAuth sessions and long-lived MCP tokens behind both admin shells: both lists load + * once on mount, a session can be revoked and a token deleted after a confirm step, and a + * session's scope chips can be expanded. The desktop cards and the phone cards render + * their own markup over it. + */ +export function useMcpTokensAdmin() { + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(true); + const [tokens, setTokens] = useState([]); + const [tokensLoading, setTokensLoading] = useState(true); + const [expandedScopes, setExpandedScopes] = useState>(new Set()); + const [revokeConfirmId, setRevokeConfirmId] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const toggleScopes = (id: number) => + setExpandedScopes((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const toast = useToast(); + const { t } = useTranslation(); + + // The loader runs once, but a language switch while the requests are in flight must + // still toast in the current locale, so the error toast reads the latest t. + const loadError = useEffectEvent((key: string) => toast.error(t(key))); + + useEffect(() => { + adminApi + .oauthSessions() + .then((d) => setSessions(d.sessions || [])) + .catch(() => loadError('admin.oauthSessions.loadError')) + .finally(() => setSessionsLoading(false)); + + adminApi + .mcpTokens() + .then((d) => setTokens(d.tokens || [])) + .catch(() => loadError('admin.mcpTokens.loadError')) + .finally(() => setTokensLoading(false)); + }, []); + + const handleRevoke = async (id: number) => { + try { + await adminApi.revokeOAuthSession(id); + setSessions((prev) => prev.filter((s) => s.id !== id)); + setRevokeConfirmId(null); + toast.success(t('admin.oauthSessions.revokeSuccess')); + } catch { + toast.error(t('admin.oauthSessions.revokeError')); + } + }; + + const handleDelete = async (id: number) => { + try { + await adminApi.deleteMcpToken(id); + setTokens((prev) => prev.filter((tk) => tk.id !== id)); + setDeleteConfirmId(null); + toast.success(t('admin.mcpTokens.deleteSuccess')); + } catch { + toast.error(t('admin.mcpTokens.deleteError')); + } + }; + + return { + sessions, + sessionsLoading, + tokens, + tokensLoading, + expandedScopes, + toggleScopes, + revokeConfirmId, + setRevokeConfirmId, + deleteConfirmId, + setDeleteConfirmId, + handleRevoke, + handleDelete, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx b/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx index f08873d5f..9b434510a 100644 --- a/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx @@ -1,96 +1,20 @@ -import { useState, useEffect, useRef } from 'react' import { Key, Trash2, User, Loader2, Shield } from 'lucide-react' -import { adminApi } from '../../../api/client' -import { useToast } from '../../../components/shared/Toast' +import { SCOPES_PREVIEW, useMcpTokensAdmin } from '../../../components/Admin/useMcpTokensAdmin' import { useTranslation } from '../../../i18n' import { MAdminCard } from './MAdminUi' import MConfirmSheet from '../settings/MConfirmSheet' -interface AdminOAuthSession { - id: number - client_id: string - client_name: string - user_id: number - username: string - scopes: string[] - access_token_expires_at: string - refresh_token_expires_at: string - created_at: string -} - -interface AdminMcpToken { - id: number - name: string - token_prefix: string - created_at: string - last_used_at: string | null - user_id: number - username: string -} - -const SCOPES_PREVIEW = 6 - // Mobile MCP tokens admin section: OAuth sessions + long-lived MCP tokens, each // as a card list with loading/empty states, expandable scope chips and a // delete confirm sheet. Drop-in for the desktop AdminMcpTokensPanel — no props, -// same adminApi calls and state machine, only the presentation is mobile. +// the state machine lives in useMcpTokensAdmin, only the presentation is mobile. export default function MAdminMcpTokensPanel() { - const [sessions, setSessions] = useState([]) - const [sessionsLoading, setSessionsLoading] = useState(true) - const [tokens, setTokens] = useState([]) - const [tokensLoading, setTokensLoading] = useState(true) - const [expandedScopes, setExpandedScopes] = useState>(new Set()) - const [revokeConfirmId, setRevokeConfirmId] = useState(null) - const [deleteConfirmId, setDeleteConfirmId] = useState(null) - - const toggleScopes = (id: number) => - setExpandedScopes(prev => { - const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) - return next - }) - const toast = useToast() + const { + sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes, + revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete, + } = useMcpTokensAdmin() const { t, locale } = useTranslation() - // The loader runs once, but a language switch while the requests are in flight - // must still toast in the current locale — hence the ref instead of the closure. - const latest = useRef({ t, toast }) - latest.current = { t, toast } - - useEffect(() => { - adminApi.oauthSessions() - .then(d => setSessions(d.sessions || [])) - .catch(() => latest.current.toast.error(latest.current.t('admin.oauthSessions.loadError'))) - .finally(() => setSessionsLoading(false)) - - adminApi.mcpTokens() - .then(d => setTokens(d.tokens || [])) - .catch(() => latest.current.toast.error(latest.current.t('admin.mcpTokens.loadError'))) - .finally(() => setTokensLoading(false)) - }, []) - - const handleRevoke = async (id: number) => { - try { - await adminApi.revokeOAuthSession(id) - setSessions(prev => prev.filter(s => s.id !== id)) - setRevokeConfirmId(null) - toast.success(t('admin.oauthSessions.revokeSuccess')) - } catch { - toast.error(t('admin.oauthSessions.revokeError')) - } - } - - const handleDelete = async (id: number) => { - try { - await adminApi.deleteMcpToken(id) - setTokens(prev => prev.filter(tk => tk.id !== id)) - setDeleteConfirmId(null) - toast.success(t('admin.mcpTokens.deleteSuccess')) - } catch { - toast.error(t('admin.mcpTokens.deleteError')) - } - } - return (
{/* Section title */}