mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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.
This commit is contained in:
@@ -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<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [tokensLoading, setTokensLoading] = useState(true)
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(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 (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<AdminOAuthSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([]);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set());
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
|
||||
const [tokensLoading, setTokensLoading] = useState(true)
|
||||
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(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 (
|
||||
<div className="space-y-3">
|
||||
{/* Section title */}
|
||||
|
||||
Reference in New Issue
Block a user