From 4b1286d53cffac278e77d3fdafeb0ee1471d42a3 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 10 Apr 2026 06:47:23 +0200 Subject: [PATCH] feat(admin): add OAuth sessions to MCP Access panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show active OAuth sessions (first) and static API tokens (second) in the admin MCP Access tab. Admins can revoke any OAuth session, which immediately terminates the live MCP transport for that client. - Add admin-level listOAuthSessions / revokeOAuthSession in adminService - Add GET /admin/oauth-sessions and DELETE /admin/oauth-sessions/:id routes - Restructure AdminMcpTokensPanel into two sections; rename tab to MCP Access - Fix stale writeAudit call in rotate-jwt-secret route (user_id → userId) - Add admin.oauthSessions.* i18n keys across all 14 locale files --- client/src/api/client.ts | 2 + .../components/Admin/AdminMcpTokensPanel.tsx | 207 +++++++++++++----- client/src/i18n/translations/ar.ts | 18 +- client/src/i18n/translations/br.ts | 18 +- client/src/i18n/translations/cs.ts | 18 +- client/src/i18n/translations/de.ts | 18 +- client/src/i18n/translations/en.ts | 18 +- client/src/i18n/translations/es.ts | 18 +- client/src/i18n/translations/fr.ts | 18 +- client/src/i18n/translations/hu.ts | 18 +- client/src/i18n/translations/it.ts | 18 +- client/src/i18n/translations/nl.ts | 18 +- client/src/i18n/translations/pl.ts | 18 +- client/src/i18n/translations/ru.ts | 18 +- client/src/i18n/translations/zh.ts | 18 +- client/src/i18n/translations/zhTw.ts | 18 +- server/src/routes/admin.ts | 25 ++- server/src/services/adminService.ts | 26 ++- 18 files changed, 415 insertions(+), 97 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ed6febb7..7ba7f19b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -232,6 +232,8 @@ export const adminApi = { apiClient.get('/admin/audit-log', { params }).then(r => r.data), mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data), deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), + oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data), + revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index 8a89f92d..0a32435d 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -1,9 +1,21 @@ import { useState, useEffect } from 'react' import { adminApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { Key, Trash2, User, Loader2 } from 'lucide-react' +import { Key, Trash2, User, Loader2, Shield } from 'lucide-react' import { useTranslation } from '../../i18n' +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 @@ -15,20 +27,38 @@ interface AdminMcpToken { } export default function AdminMcpTokensPanel() { + const [sessions, setSessions] = useState([]) + const [sessionsLoading, setSessionsLoading] = useState(true) const [tokens, setTokens] = useState([]) - const [isLoading, setIsLoading] = useState(true) + const [tokensLoading, setTokensLoading] = useState(true) + const [revokeConfirmId, setRevokeConfirmId] = useState(null) const [deleteConfirmId, setDeleteConfirmId] = useState(null) const toast = useToast() const { t, locale } = useTranslation() useEffect(() => { - setIsLoading(true) + 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(() => setIsLoading(false)) + .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) @@ -47,55 +77,134 @@ export default function AdminMcpTokensPanel() {

{t('admin.mcpTokens.subtitle')}

-
- {isLoading ? ( -
- -
- ) : tokens.length === 0 ? ( -
- -

{t('admin.mcpTokens.empty')}

-
- ) : ( - <> -
- {t('admin.mcpTokens.tokenName')} - {t('admin.mcpTokens.owner')} - {t('admin.mcpTokens.created')} - {t('admin.mcpTokens.lastUsed')} - + {/* OAuth Sessions */} +
+

{t('admin.oauthSessions.sectionTitle')}

+
+ {sessionsLoading ? ( +
+
- {tokens.map((token, i) => ( -
-
-

{token.name}

-

{token.token_prefix}...

-
-
- - {token.username} -
- - {new Date(token.created_at).toLocaleDateString(locale)} - - - {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} - - + ) : sessions.length === 0 ? ( +
+ +

{t('admin.oauthSessions.empty')}

+
+ ) : ( + <> +
+ {t('admin.oauthSessions.clientName')} + {t('admin.oauthSessions.owner')} + {t('admin.oauthSessions.scopes')} + {t('admin.oauthSessions.created')} +
- ))} - - )} + {sessions.map((session, i) => ( +
+
+

{session.client_name}

+

{session.client_id}

+
+
+ + {session.username} +
+ + {session.scopes.join(', ')} + + + {new Date(session.created_at).toLocaleDateString(locale)} + + +
+ ))} + + )} +
+ {/* MCP Tokens */} +
+

{t('admin.mcpTokens.sectionTitle')}

+
+ {tokensLoading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +
+ +

{t('admin.mcpTokens.empty')}

+
+ ) : ( + <> +
+ {t('admin.mcpTokens.tokenName')} + {t('admin.mcpTokens.owner')} + {t('admin.mcpTokens.created')} + {t('admin.mcpTokens.lastUsed')} + +
+ {tokens.map((token, i) => ( +
+
+

{token.name}

+

{token.token_prefix}...

+
+
+ + {token.username} +
+ + {new Date(token.created_at).toLocaleDateString(locale)} + + + {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} + + +
+ ))} + + )} +
+
+ + {/* Revoke OAuth session modal */} + {revokeConfirmId !== null && ( +
{ if (e.target === e.currentTarget) setRevokeConfirmId(null) }}> +
+

{t('admin.oauthSessions.revokeTitle')}

+

{t('admin.oauthSessions.revokeMessage')}

+
+ + +
+
+
+ )} + + {/* Delete MCP token modal */} {deleteConfirmId !== null && (
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 96474671..ef3c216f 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -411,9 +411,10 @@ const ar: Record = { 'admin.tabs.config': 'التخصيص', 'admin.tabs.templates': 'قوالب التعبئة', 'admin.tabs.addons': 'الإضافات', - 'admin.tabs.mcpTokens': 'رموز MCP', - 'admin.mcpTokens.title': 'رموز MCP', - 'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين', + 'admin.tabs.mcpTokens': 'وصول MCP', + 'admin.mcpTokens.title': 'وصول MCP', + 'admin.mcpTokens.subtitle': 'إدارة جلسات OAuth ورموز API لجميع المستخدمين', + 'admin.mcpTokens.sectionTitle': 'رموز API', 'admin.mcpTokens.owner': 'المالك', 'admin.mcpTokens.tokenName': 'اسم الرمز', 'admin.mcpTokens.created': 'تاريخ الإنشاء', @@ -425,6 +426,17 @@ const ar: Record = { 'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز', 'admin.mcpTokens.deleteError': 'فشل حذف الرمز', 'admin.mcpTokens.loadError': 'فشل تحميل الرموز', + 'admin.oauthSessions.sectionTitle': 'جلسات OAuth', + 'admin.oauthSessions.clientName': 'العميل', + 'admin.oauthSessions.owner': 'المالك', + 'admin.oauthSessions.scopes': 'الصلاحيات', + 'admin.oauthSessions.created': 'تاريخ الإنشاء', + 'admin.oauthSessions.empty': 'لا توجد جلسات OAuth نشطة', + 'admin.oauthSessions.revokeTitle': 'إلغاء الجلسة', + 'admin.oauthSessions.revokeMessage': 'سيتم إلغاء جلسة OAuth هذه فوراً. سيفقد العميل وصوله إلى MCP.', + 'admin.oauthSessions.revokeSuccess': 'تم إلغاء الجلسة', + 'admin.oauthSessions.revokeError': 'فشل إلغاء الجلسة', + 'admin.oauthSessions.loadError': 'فشل تحميل جلسات OAuth', 'admin.tabs.github': 'GitHub', 'admin.stats.users': 'المستخدمون', 'admin.stats.trips': 'الرحلات', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index af7af325..813ccb72 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1478,9 +1478,10 @@ const br: Record = { // Permissions 'admin.tabs.permissions': 'Permissões', - 'admin.tabs.mcpTokens': 'Tokens MCP', - 'admin.mcpTokens.title': 'Tokens MCP', - 'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários', + 'admin.tabs.mcpTokens': 'Acesso MCP', + 'admin.mcpTokens.title': 'Acesso MCP', + 'admin.mcpTokens.subtitle': 'Gerenciar sessões OAuth e tokens de API de todos os usuários', + 'admin.mcpTokens.sectionTitle': 'Tokens de API', 'admin.mcpTokens.owner': 'Proprietário', 'admin.mcpTokens.tokenName': 'Nome do Token', 'admin.mcpTokens.created': 'Criado', @@ -1492,6 +1493,17 @@ const br: Record = { 'admin.mcpTokens.deleteSuccess': 'Token excluído', 'admin.mcpTokens.deleteError': 'Falha ao excluir token', 'admin.mcpTokens.loadError': 'Falha ao carregar tokens', + 'admin.oauthSessions.sectionTitle': 'Sessões OAuth', + 'admin.oauthSessions.clientName': 'Cliente', + 'admin.oauthSessions.owner': 'Proprietário', + 'admin.oauthSessions.scopes': 'Permissões', + 'admin.oauthSessions.created': 'Criado', + 'admin.oauthSessions.empty': 'Nenhuma sessão OAuth ativa', + 'admin.oauthSessions.revokeTitle': 'Revogar sessão', + 'admin.oauthSessions.revokeMessage': 'Esta sessão OAuth será revogada imediatamente. O cliente perderá o acesso MCP.', + 'admin.oauthSessions.revokeSuccess': 'Sessão revogada', + 'admin.oauthSessions.revokeError': 'Falha ao revogar sessão', + 'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth', 'perm.title': 'Configurações de Permissões', 'perm.subtitle': 'Controle quem pode realizar ações no aplicativo', 'perm.saved': 'Configurações de permissões salvas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index f0eca4ca..e6b61b19 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -551,9 +551,10 @@ const cs: Record = { 'admin.audit.col.details': 'Detaily', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP tokeny', - 'admin.mcpTokens.title': 'MCP tokeny', - 'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů', + 'admin.tabs.mcpTokens': 'MCP přístup', + 'admin.mcpTokens.title': 'MCP přístup', + 'admin.mcpTokens.subtitle': 'Správa OAuth relací a API tokenů všech uživatelů', + 'admin.mcpTokens.sectionTitle': 'API tokeny', 'admin.mcpTokens.owner': 'Vlastník', 'admin.mcpTokens.tokenName': 'Název tokenu', 'admin.mcpTokens.created': 'Vytvořen', @@ -565,6 +566,17 @@ const cs: Record = { 'admin.mcpTokens.deleteSuccess': 'Token smazán', 'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token', 'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny', + 'admin.oauthSessions.sectionTitle': 'OAuth relace', + 'admin.oauthSessions.clientName': 'Klient', + 'admin.oauthSessions.owner': 'Vlastník', + 'admin.oauthSessions.scopes': 'Oprávnění', + 'admin.oauthSessions.created': 'Vytvořeno', + 'admin.oauthSessions.empty': 'Žádné aktivní OAuth relace', + 'admin.oauthSessions.revokeTitle': 'Zrušit relaci', + 'admin.oauthSessions.revokeMessage': 'Tato OAuth relace bude okamžitě zrušena. Klient ztratí přístup k MCP.', + 'admin.oauthSessions.revokeSuccess': 'Relace zrušena', + 'admin.oauthSessions.revokeError': 'Nepodařilo se zrušit relaci', + 'admin.oauthSessions.loadError': 'Nepodařilo se načíst OAuth relace', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7018c775..469b43ef 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -549,9 +549,10 @@ const de: Record = { 'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP-Tokens', - 'admin.mcpTokens.title': 'MCP-Tokens', - 'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten', + 'admin.tabs.mcpTokens': 'MCP-Zugang', + 'admin.mcpTokens.title': 'MCP-Zugang', + 'admin.mcpTokens.subtitle': 'OAuth-Sitzungen und API-Tokens aller Benutzer verwalten', + 'admin.mcpTokens.sectionTitle': 'API-Tokens', 'admin.mcpTokens.owner': 'Besitzer', 'admin.mcpTokens.tokenName': 'Token-Name', 'admin.mcpTokens.created': 'Erstellt', @@ -563,6 +564,17 @@ const de: Record = { 'admin.mcpTokens.deleteSuccess': 'Token gelöscht', 'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden', 'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden', + 'admin.oauthSessions.sectionTitle': 'OAuth-Sitzungen', + 'admin.oauthSessions.clientName': 'Client', + 'admin.oauthSessions.owner': 'Besitzer', + 'admin.oauthSessions.scopes': 'Berechtigungen', + 'admin.oauthSessions.created': 'Erstellt', + 'admin.oauthSessions.empty': 'Keine aktiven OAuth-Sitzungen', + 'admin.oauthSessions.revokeTitle': 'Sitzung widerrufen', + 'admin.oauthSessions.revokeMessage': 'Diese OAuth-Sitzung wird sofort widerrufen. Der Client verliert den MCP-Zugang.', + 'admin.oauthSessions.revokeSuccess': 'Sitzung widerrufen', + 'admin.oauthSessions.revokeError': 'Sitzung konnte nicht widerrufen werden', + 'admin.oauthSessions.loadError': 'OAuth-Sitzungen konnten nicht geladen werden', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index d0b79891..a3d9d176 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -613,9 +613,10 @@ const en: Record = { 'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.', // GitHub - 'admin.tabs.mcpTokens': 'MCP Tokens', - 'admin.mcpTokens.title': 'MCP Tokens', - 'admin.mcpTokens.subtitle': 'Manage API tokens across all users', + 'admin.tabs.mcpTokens': 'MCP Access', + 'admin.mcpTokens.title': 'MCP Access', + 'admin.mcpTokens.subtitle': 'Manage OAuth sessions and API tokens across all users', + 'admin.mcpTokens.sectionTitle': 'API Tokens', 'admin.mcpTokens.owner': 'Owner', 'admin.mcpTokens.tokenName': 'Token Name', 'admin.mcpTokens.created': 'Created', @@ -627,6 +628,17 @@ const en: Record = { 'admin.mcpTokens.deleteSuccess': 'Token deleted', 'admin.mcpTokens.deleteError': 'Failed to delete token', 'admin.mcpTokens.loadError': 'Failed to load tokens', + 'admin.oauthSessions.sectionTitle': 'OAuth Sessions', + 'admin.oauthSessions.clientName': 'Client', + 'admin.oauthSessions.owner': 'Owner', + 'admin.oauthSessions.scopes': 'Scopes', + 'admin.oauthSessions.created': 'Created', + 'admin.oauthSessions.empty': 'No active OAuth sessions', + 'admin.oauthSessions.revokeTitle': 'Revoke Session', + 'admin.oauthSessions.revokeMessage': 'This will revoke the OAuth session immediately. The client will lose MCP access.', + 'admin.oauthSessions.revokeSuccess': 'Session revoked', + 'admin.oauthSessions.revokeError': 'Failed to revoke session', + 'admin.oauthSessions.loadError': 'Failed to load OAuth sessions', 'admin.tabs.github': 'GitHub', 'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 8b462f2a..94ab8d13 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -526,9 +526,10 @@ const es: Record = { 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.', // MCP Tokens - 'admin.tabs.mcpTokens': 'Tokens MCP', - 'admin.mcpTokens.title': 'Tokens MCP', - 'admin.mcpTokens.subtitle': 'Gestionar tokens de API de todos los usuarios', + 'admin.tabs.mcpTokens': 'Acceso MCP', + 'admin.mcpTokens.title': 'Acceso MCP', + 'admin.mcpTokens.subtitle': 'Gestionar sesiones OAuth y tokens de API de todos los usuarios', + 'admin.mcpTokens.sectionTitle': 'Tokens de API', 'admin.mcpTokens.owner': 'Propietario', 'admin.mcpTokens.tokenName': 'Nombre del token', 'admin.mcpTokens.created': 'Creado', @@ -540,6 +541,17 @@ const es: Record = { 'admin.mcpTokens.deleteSuccess': 'Token eliminado', 'admin.mcpTokens.deleteError': 'No se pudo eliminar el token', 'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens', + 'admin.oauthSessions.sectionTitle': 'Sesiones OAuth', + 'admin.oauthSessions.clientName': 'Cliente', + 'admin.oauthSessions.owner': 'Propietario', + 'admin.oauthSessions.scopes': 'Permisos', + 'admin.oauthSessions.created': 'Creado', + 'admin.oauthSessions.empty': 'No hay sesiones OAuth activas', + 'admin.oauthSessions.revokeTitle': 'Revocar sesión', + 'admin.oauthSessions.revokeMessage': 'Esto revocará la sesión OAuth inmediatamente. El cliente perderá el acceso MCP.', + 'admin.oauthSessions.revokeSuccess': 'Sesión revocada', + 'admin.oauthSessions.revokeError': 'No se pudo revocar la sesión', + 'admin.oauthSessions.loadError': 'No se pudieron cargar las sesiones OAuth', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 340125f9..778ae84a 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -561,9 +561,10 @@ const fr: Record = { 'admin.audit.col.details': 'Détails', // MCP Tokens - 'admin.tabs.mcpTokens': 'Tokens MCP', - 'admin.mcpTokens.title': 'Tokens MCP', - 'admin.mcpTokens.subtitle': 'Gérer les tokens API de tous les utilisateurs', + 'admin.tabs.mcpTokens': 'Accès MCP', + 'admin.mcpTokens.title': 'Accès MCP', + 'admin.mcpTokens.subtitle': 'Gérer les sessions OAuth et les tokens API de tous les utilisateurs', + 'admin.mcpTokens.sectionTitle': 'Tokens API', 'admin.mcpTokens.owner': 'Propriétaire', 'admin.mcpTokens.tokenName': 'Nom du token', 'admin.mcpTokens.created': 'Créé', @@ -575,6 +576,17 @@ const fr: Record = { 'admin.mcpTokens.deleteSuccess': 'Token supprimé', 'admin.mcpTokens.deleteError': 'Impossible de supprimer le token', 'admin.mcpTokens.loadError': 'Impossible de charger les tokens', + 'admin.oauthSessions.sectionTitle': 'Sessions OAuth', + 'admin.oauthSessions.clientName': 'Client', + 'admin.oauthSessions.owner': 'Propriétaire', + 'admin.oauthSessions.scopes': 'Portées', + 'admin.oauthSessions.created': 'Créé', + 'admin.oauthSessions.empty': 'Aucune session OAuth active', + 'admin.oauthSessions.revokeTitle': 'Révoquer la session', + 'admin.oauthSessions.revokeMessage': 'Cette session OAuth sera révoquée immédiatement. Le client perdra l\'accès MCP.', + 'admin.oauthSessions.revokeSuccess': 'Session révoquée', + 'admin.oauthSessions.revokeError': 'Impossible de révoquer la session', + 'admin.oauthSessions.loadError': 'Impossible de charger les sessions OAuth', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 14982e4f..5dedf5f1 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -562,9 +562,10 @@ const hu: Record = { 'admin.audit.col.details': 'Részletek', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP tokenek', - 'admin.mcpTokens.title': 'MCP tokenek', - 'admin.mcpTokens.subtitle': 'Összes felhasználó API tokeneinek kezelése', + 'admin.tabs.mcpTokens': 'MCP hozzáférés', + 'admin.mcpTokens.title': 'MCP hozzáférés', + 'admin.mcpTokens.subtitle': 'OAuth munkamenetek és API tokenek kezelése az összes felhasználó számára', + 'admin.mcpTokens.sectionTitle': 'API tokenek', 'admin.mcpTokens.owner': 'Tulajdonos', 'admin.mcpTokens.tokenName': 'Token neve', 'admin.mcpTokens.created': 'Létrehozva', @@ -576,6 +577,17 @@ const hu: Record = { 'admin.mcpTokens.deleteSuccess': 'Token törölve', 'admin.mcpTokens.deleteError': 'Nem sikerült törölni a tokent', 'admin.mcpTokens.loadError': 'Nem sikerült betölteni a tokeneket', + 'admin.oauthSessions.sectionTitle': 'OAuth munkamenetek', + 'admin.oauthSessions.clientName': 'Kliens', + 'admin.oauthSessions.owner': 'Tulajdonos', + 'admin.oauthSessions.scopes': 'Jogosultságok', + 'admin.oauthSessions.created': 'Létrehozva', + 'admin.oauthSessions.empty': 'Nincsenek aktív OAuth munkamenetek', + 'admin.oauthSessions.revokeTitle': 'Munkamenet visszavonása', + 'admin.oauthSessions.revokeMessage': 'Ez az OAuth munkamenet azonnal visszavonásra kerül. A kliens elveszíti az MCP hozzáférést.', + 'admin.oauthSessions.revokeSuccess': 'Munkamenet visszavonva', + 'admin.oauthSessions.revokeError': 'Nem sikerült visszavonni a munkamenetet', + 'admin.oauthSessions.loadError': 'Nem sikerült betölteni az OAuth munkameneteket', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 6ca0c21b..beaa728a 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -562,9 +562,10 @@ const it: Record = { 'admin.audit.col.details': 'Dettagli', // MCP Tokens - 'admin.tabs.mcpTokens': 'Token MCP', - 'admin.mcpTokens.title': 'Token MCP', - 'admin.mcpTokens.subtitle': 'Gestisci i token API di tutti gli utenti', + 'admin.tabs.mcpTokens': 'Accesso MCP', + 'admin.mcpTokens.title': 'Accesso MCP', + 'admin.mcpTokens.subtitle': 'Gestisci le sessioni OAuth e i token API di tutti gli utenti', + 'admin.mcpTokens.sectionTitle': 'Token API', 'admin.mcpTokens.owner': 'Proprietario', 'admin.mcpTokens.tokenName': 'Nome token', 'admin.mcpTokens.created': 'Creato', @@ -576,6 +577,17 @@ const it: Record = { 'admin.mcpTokens.deleteSuccess': 'Token eliminato', 'admin.mcpTokens.deleteError': 'Impossibile eliminare il token', 'admin.mcpTokens.loadError': 'Impossibile caricare i token', + 'admin.oauthSessions.sectionTitle': 'Sessioni OAuth', + 'admin.oauthSessions.clientName': 'Client', + 'admin.oauthSessions.owner': 'Proprietario', + 'admin.oauthSessions.scopes': 'Ambiti', + 'admin.oauthSessions.created': 'Creato', + 'admin.oauthSessions.empty': 'Nessuna sessione OAuth attiva', + 'admin.oauthSessions.revokeTitle': 'Revoca sessione', + 'admin.oauthSessions.revokeMessage': 'Questa sessione OAuth verrà revocata immediatamente. Il client perderà l\'accesso MCP.', + 'admin.oauthSessions.revokeSuccess': 'Sessione revocata', + 'admin.oauthSessions.revokeError': 'Impossibile revocare la sessione', + 'admin.oauthSessions.loadError': 'Impossibile caricare le sessioni OAuth', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 4891b9e9..d202e2e3 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -548,9 +548,10 @@ const nl: Record = { 'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP-tokens', - 'admin.mcpTokens.title': 'MCP-tokens', - 'admin.mcpTokens.subtitle': 'API-tokens van alle gebruikers beheren', + 'admin.tabs.mcpTokens': 'MCP-toegang', + 'admin.mcpTokens.title': 'MCP-toegang', + 'admin.mcpTokens.subtitle': 'OAuth-sessies en API-tokens van alle gebruikers beheren', + 'admin.mcpTokens.sectionTitle': 'API-tokens', 'admin.mcpTokens.owner': 'Eigenaar', 'admin.mcpTokens.tokenName': 'Tokennaam', 'admin.mcpTokens.created': 'Aangemaakt', @@ -562,6 +563,17 @@ const nl: Record = { 'admin.mcpTokens.deleteSuccess': 'Token verwijderd', 'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd', 'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen', + 'admin.oauthSessions.sectionTitle': 'OAuth-sessies', + 'admin.oauthSessions.clientName': 'Client', + 'admin.oauthSessions.owner': 'Eigenaar', + 'admin.oauthSessions.scopes': 'Rechten', + 'admin.oauthSessions.created': 'Aangemaakt', + 'admin.oauthSessions.empty': 'Geen actieve OAuth-sessies', + 'admin.oauthSessions.revokeTitle': 'Sessie intrekken', + 'admin.oauthSessions.revokeMessage': 'Deze OAuth-sessie wordt onmiddellijk ingetrokken. De client verliest MCP-toegang.', + 'admin.oauthSessions.revokeSuccess': 'Sessie ingetrokken', + 'admin.oauthSessions.revokeError': 'Sessie kon niet worden ingetrokken', + 'admin.oauthSessions.loadError': 'OAuth-sessies konden niet worden geladen', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 4eb3c345..83c55cdf 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -517,9 +517,10 @@ const pl: Record = { 'admin.weather.locationHint': 'Pogoda jest określana na podstawie pierwszego miejsca z przypisanymi współrzędnymi w danym dniu. Jeśli do dnia nie przypisano żadnego miejsca, jako punkt odniesienia używane jest dowolne miejsce z listy.', // GitHub - 'admin.tabs.mcpTokens': 'Tokeny MCP', - 'admin.mcpTokens.title': 'Tokeny MCP', - 'admin.mcpTokens.subtitle': 'Zarządzaj tokenami API dla wszystkich użytkowników', + 'admin.tabs.mcpTokens': 'Dostęp MCP', + 'admin.mcpTokens.title': 'Dostęp MCP', + 'admin.mcpTokens.subtitle': 'Zarządzaj sesjami OAuth i tokenami API dla wszystkich użytkowników', + 'admin.mcpTokens.sectionTitle': 'Tokeny API', 'admin.mcpTokens.owner': 'Właściciel', 'admin.mcpTokens.tokenName': 'Nazwa tokenu', 'admin.mcpTokens.created': 'Utworzono', @@ -531,6 +532,17 @@ const pl: Record = { 'admin.mcpTokens.deleteSuccess': 'Token został usunięty', 'admin.mcpTokens.deleteError': 'Nie udało się usunąć tokenu', 'admin.mcpTokens.loadError': 'Nie udało się załadować tokenów', + 'admin.oauthSessions.sectionTitle': 'Sesje OAuth', + 'admin.oauthSessions.clientName': 'Klient', + 'admin.oauthSessions.owner': 'Właściciel', + 'admin.oauthSessions.scopes': 'Uprawnienia', + 'admin.oauthSessions.created': 'Utworzono', + 'admin.oauthSessions.empty': 'Brak aktywnych sesji OAuth', + 'admin.oauthSessions.revokeTitle': 'Unieważnij sesję', + 'admin.oauthSessions.revokeMessage': 'Ta sesja OAuth zostanie natychmiast unieważniona. Klient straci dostęp do MCP.', + 'admin.oauthSessions.revokeSuccess': 'Sesja unieważniona', + 'admin.oauthSessions.revokeError': 'Nie udało się unieważnić sesji', + 'admin.oauthSessions.loadError': 'Nie udało się załadować sesji OAuth', 'admin.tabs.github': 'GitHub', 'admin.audit.subtitle': 'Zdarzenia związane z bezpieczeństwem i administracją (kopie zapasowe, użytkownicy, MFA, ustawienia).', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index c3f53588..9ec079a8 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -548,9 +548,10 @@ const ru: Record = { 'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP-токены', - 'admin.mcpTokens.title': 'MCP-токены', - 'admin.mcpTokens.subtitle': 'Управление API-токенами всех пользователей', + 'admin.tabs.mcpTokens': 'MCP-доступ', + 'admin.mcpTokens.title': 'MCP-доступ', + 'admin.mcpTokens.subtitle': 'Управление OAuth-сессиями и API-токенами всех пользователей', + 'admin.mcpTokens.sectionTitle': 'API-токены', 'admin.mcpTokens.owner': 'Владелец', 'admin.mcpTokens.tokenName': 'Название токена', 'admin.mcpTokens.created': 'Создан', @@ -562,6 +563,17 @@ const ru: Record = { 'admin.mcpTokens.deleteSuccess': 'Токен удалён', 'admin.mcpTokens.deleteError': 'Не удалось удалить токен', 'admin.mcpTokens.loadError': 'Не удалось загрузить токены', + 'admin.oauthSessions.sectionTitle': 'OAuth-сессии', + 'admin.oauthSessions.clientName': 'Клиент', + 'admin.oauthSessions.owner': 'Владелец', + 'admin.oauthSessions.scopes': 'Права доступа', + 'admin.oauthSessions.created': 'Создано', + 'admin.oauthSessions.empty': 'Нет активных OAuth-сессий', + 'admin.oauthSessions.revokeTitle': 'Отозвать сессию', + 'admin.oauthSessions.revokeMessage': 'Эта OAuth-сессия будет немедленно отозвана. Клиент потеряет доступ к MCP.', + 'admin.oauthSessions.revokeSuccess': 'Сессия отозвана', + 'admin.oauthSessions.revokeError': 'Не удалось отозвать сессию', + 'admin.oauthSessions.loadError': 'Не удалось загрузить OAuth-сессии', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 43e95f11..1b760893 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -548,9 +548,10 @@ const zh: Record = { 'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP 令牌', - 'admin.mcpTokens.title': 'MCP 令牌', - 'admin.mcpTokens.subtitle': '管理所有用户的 API 令牌', + 'admin.tabs.mcpTokens': 'MCP 访问', + 'admin.mcpTokens.title': 'MCP 访问', + 'admin.mcpTokens.subtitle': '管理所有用户的 OAuth 会话和 API 令牌', + 'admin.mcpTokens.sectionTitle': 'API 令牌', 'admin.mcpTokens.owner': '所有者', 'admin.mcpTokens.tokenName': '令牌名称', 'admin.mcpTokens.created': '创建时间', @@ -562,6 +563,17 @@ const zh: Record = { 'admin.mcpTokens.deleteSuccess': '令牌已删除', 'admin.mcpTokens.deleteError': '删除令牌失败', 'admin.mcpTokens.loadError': '加载令牌失败', + 'admin.oauthSessions.sectionTitle': 'OAuth 会话', + 'admin.oauthSessions.clientName': '客户端', + 'admin.oauthSessions.owner': '所有者', + 'admin.oauthSessions.scopes': '权限范围', + 'admin.oauthSessions.created': '创建时间', + 'admin.oauthSessions.empty': '暂无活跃的 OAuth 会话', + 'admin.oauthSessions.revokeTitle': '撤销会话', + 'admin.oauthSessions.revokeMessage': '此 OAuth 会话将立即被撤销。客户端将失去 MCP 访问权限。', + 'admin.oauthSessions.revokeSuccess': '会话已撤销', + 'admin.oauthSessions.revokeError': '撤销会话失败', + 'admin.oauthSessions.loadError': '加载 OAuth 会话失败', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 19a256d5..b156911b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -532,9 +532,10 @@ const zhTw: Record = { 'admin.weather.locationHint': '天氣基於每天中第一個有座標的地點。如果當天沒有分配地點,則使用地點列表中的任意地點作為參考。', // MCP Tokens - 'admin.tabs.mcpTokens': 'MCP 令牌', - 'admin.mcpTokens.title': 'MCP 令牌', - 'admin.mcpTokens.subtitle': '管理所有使用者的 API 令牌', + 'admin.tabs.mcpTokens': 'MCP 存取', + 'admin.mcpTokens.title': 'MCP 存取', + 'admin.mcpTokens.subtitle': '管理所有使用者的 OAuth 工作階段和 API 令牌', + 'admin.mcpTokens.sectionTitle': 'API 令牌', 'admin.mcpTokens.owner': '所有者', 'admin.mcpTokens.tokenName': '令牌名稱', 'admin.mcpTokens.created': '建立時間', @@ -546,6 +547,17 @@ const zhTw: Record = { 'admin.mcpTokens.deleteSuccess': '令牌已刪除', 'admin.mcpTokens.deleteError': '刪除令牌失敗', 'admin.mcpTokens.loadError': '載入令牌失敗', + 'admin.oauthSessions.sectionTitle': 'OAuth 工作階段', + 'admin.oauthSessions.clientName': '客戶端', + 'admin.oauthSessions.owner': '所有者', + 'admin.oauthSessions.scopes': '權限範圍', + 'admin.oauthSessions.created': '建立時間', + 'admin.oauthSessions.empty': '目前沒有活躍的 OAuth 工作階段', + 'admin.oauthSessions.revokeTitle': '撤銷工作階段', + 'admin.oauthSessions.revokeMessage': '此 OAuth 工作階段將立即被撤銷。客戶端將失去 MCP 存取權限。', + 'admin.oauthSessions.revokeSuccess': '工作階段已撤銷', + 'admin.oauthSessions.revokeError': '撤銷工作階段失敗', + 'admin.oauthSessions.loadError': '載入 OAuth 工作階段失敗', // GitHub 'admin.tabs.github': 'GitHub', diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 56cfa46d..870ecdeb 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -307,6 +307,25 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { res.json({ success: true }); }); +// ── OAuth Sessions ───────────────────────────────────────────────────────── + +router.get('/oauth-sessions', (_req: Request, res: Response) => { + res.json({ sessions: svc.listOAuthSessions() }); +}); + +router.delete('/oauth-sessions/:id', (req: Request, res: Response) => { + const result = svc.revokeOAuthSession(req.params.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.oauth_session.revoke', + resource: String(req.params.id), + ip: getClientIp(req), + }); + res.json({ success: true }); +}); + // ── JWT Rotation ─────────────────────────────────────────────────────────── router.post('/rotate-jwt-secret', (req: Request, res: Response) => { @@ -314,12 +333,8 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => { if (result.error) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ - user_id: authReq.user?.id ?? null, - username: authReq.user?.username ?? 'unknown', + userId: authReq.user.id, action: 'admin.rotate_jwt_secret', - target_type: 'system', - target_id: null, - details: null, ip: getClientIp(req), }); res.json({ success: true }); diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 9ca96604..7566e240 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -7,7 +7,7 @@ import { User, Addon } from '../types'; import { updateJwtSecret } from '../config'; import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions'; -import { revokeUserSessions } from '../mcp'; +import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp'; import { validatePassword } from './passwordPolicy'; import { getPhotoProviderConfig } from './memories/helpersService'; import { send as sendNotification } from './notificationService'; @@ -603,6 +603,30 @@ export function deleteMcpToken(id: string) { return {}; } +// ── OAuth Sessions ───────────────────────────────────────────────────────── + +export function listOAuthSessions() { + const rows = db.prepare(` + SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username, + ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at + FROM oauth_tokens ot + JOIN oauth_clients oc ON ot.client_id = oc.client_id + JOIN users u ON u.id = ot.user_id + WHERE ot.revoked_at IS NULL + AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP + ORDER BY ot.created_at DESC + `).all() as (Record & { scopes: string })[]; + return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) })); +} + +export function revokeOAuthSession(id: string) { + const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined; + if (!row) return { error: 'Session not found', status: 404 }; + db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); + revokeUserSessionsForClient(row.user_id, row.client_id); + return {}; +} + // ── JWT Rotation ─────────────────────────────────────────────────────────── export function rotateJwtSecret(): { error?: string; status?: number } {