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 */}