feat: remove Account page and add PassthroughLanding page
- Deleted the Account page and its associated logic. - Introduced a new PassthroughLanding page for users without management access. - Updated Settings page to conditionally display the Users link for admin users. - Enhanced UsersPage to support passthrough user role, including invite functionality and user detail modal. - Updated tests to reflect changes in user roles and navigation.
This commit is contained in:
@@ -7,6 +7,7 @@ import { ToastContainer } from './components/Toast'
|
||||
import { SetupGuard } from './components/SetupGuard'
|
||||
import { LoadingOverlay } from './components/LoadingStates'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
import RequireRole from './components/RequireRole'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
@@ -23,7 +24,6 @@ const DNSProviders = lazy(() => import('./pages/DNSProviders'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
const SMTPSettings = lazy(() => import('./pages/SMTPSettings'))
|
||||
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
|
||||
const Account = lazy(() => import('./pages/Account'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Backups = lazy(() => import('./pages/Backups'))
|
||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||
@@ -43,6 +43,7 @@ const Plugins = lazy(() => import('./pages/Plugins'))
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Setup = lazy(() => import('./pages/Setup'))
|
||||
const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
|
||||
const PassthroughLanding = lazy(() => import('./pages/PassthroughLanding'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -53,6 +54,11 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/passthrough" element={
|
||||
<RequireAuth>
|
||||
<PassthroughLanding />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
@@ -88,7 +94,9 @@ export default function App() {
|
||||
<Route path="security/encryption" element={<EncryptionManagement />} />
|
||||
<Route path="access-lists" element={<AccessLists />} />
|
||||
<Route path="uptime" element={<Uptime />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
|
||||
{/* Legacy redirects for old user management paths */}
|
||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="admin/plugins" element={<Navigate to="/dns/plugins" replace />} />
|
||||
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
|
||||
|
||||
@@ -99,8 +107,10 @@ export default function App() {
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="smtp" element={<SMTPSettings />} />
|
||||
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
<Route path="account-management" element={<UsersPage />} />
|
||||
<Route path="users" element={<RequireRole allowed={['admin']}><UsersPage /></RequireRole>} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="account" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="account-management" element={<Navigate to="/settings/users" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Tasks Routes */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import client from '../client'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../user'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../users'
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
* @throws {AxiosError} If the request fails or not authenticated
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
* @throws {AxiosError} If regeneration fails
|
||||
*/
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile.
|
||||
* @param data - Object with name, email, and optional current_password for verification
|
||||
* @returns Promise resolving to success message
|
||||
* @throws {AxiosError} If update fails or password verification fails
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface User {
|
||||
uuid: string
|
||||
email: string
|
||||
name: string
|
||||
role: 'admin' | 'user' | 'viewer'
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
enabled: boolean
|
||||
last_login?: string
|
||||
invite_status?: 'pending' | 'accepted' | 'expired'
|
||||
@@ -212,3 +212,51 @@ export const resendInvite = async (id: number): Promise<InviteUserResponse> => {
|
||||
const response = await client.post<InviteUserResponse>(`/users/${id}/resend-invite`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// --- Self-service profile endpoints (merged from api/user.ts) ---
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/** Response from API key regeneration. */
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile.
|
||||
* @param data - Object with name, email, and optional current_password for verification
|
||||
* @returns Promise resolving to success message
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
*/
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -85,8 +85,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: t('navigation.system'), path: '/settings/system', icon: '⚙️' },
|
||||
{ name: t('navigation.notifications'), path: '/settings/notifications', icon: '🔔' },
|
||||
{ name: t('navigation.email'), path: '/settings/smtp', icon: '📧' },
|
||||
{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' },
|
||||
{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' },
|
||||
...(user?.role === 'admin' ? [{ name: t('navigation.users'), path: '/settings/users', icon: '👥' }] : []),
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -109,6 +108,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
]
|
||||
},
|
||||
].filter(item => {
|
||||
// Passthrough users see no navigation — they're redirected to /passthrough
|
||||
if (user?.role === 'passthrough') return false
|
||||
// Optional Features Logic
|
||||
// Default to visible (true) if flags are loading or undefined
|
||||
if (item.name === t('navigation.uptime')) return featureFlags?.['feature.uptime.enabled'] !== false
|
||||
@@ -362,7 +363,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
<div className="w-1/3 flex justify-end items-center gap-4">
|
||||
{user && (
|
||||
<Link to="/settings/account" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
<Link to="/settings/users" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{user.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
25
frontend/src/components/RequireRole.tsx
Normal file
25
frontend/src/components/RequireRole.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface RequireRoleProps {
|
||||
allowed: Array<'admin' | 'user' | 'passthrough'>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const RequireRole: React.FC<RequireRoleProps> = ({ allowed, children }) => {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (!allowed.includes(user.role)) {
|
||||
const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/'
|
||||
return <Navigate to={redirectTarget} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default RequireRole
|
||||
@@ -2,7 +2,7 @@ import { createContext } from 'react';
|
||||
|
||||
export interface User {
|
||||
user_id: number;
|
||||
role: string;
|
||||
role: 'admin' | 'user' | 'passthrough';
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('useAuth hook', () => {
|
||||
})
|
||||
|
||||
it('returns context inside provider', () => {
|
||||
const fakeCtx = { user: { user_id: 1, role: 'admin', name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
|
||||
const fakeCtx = { user: { user_id: 1, role: 'admin' as const, name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
|
||||
render(
|
||||
<AuthContext.Provider value={fakeCtx}>
|
||||
<TestComponent />
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"system": "System",
|
||||
"email": "E-Mail (SMTP)",
|
||||
"adminAccount": "Admin-Konto",
|
||||
"accountManagement": "Kontoverwaltung",
|
||||
"import": "Importieren",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Sicherungen",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rolle",
|
||||
"roleUser": "Benutzer",
|
||||
"roleAdmin": "Administrator",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Kann nur auf erlaubte Proxy-Hosts zugreifen.",
|
||||
"roleAdminDescription": "Vollzugriff auf alle Funktionen und Einstellungen.",
|
||||
"rolePassthroughDescription": "Nur Proxy-Zugriff — keine Verwaltungsoberfläche.",
|
||||
"permissionMode": "Berechtigungsmodus",
|
||||
"allowAllBlacklist": "Alles erlauben (Blacklist)",
|
||||
"denyAllWhitelist": "Alles verweigern (Whitelist)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Einladung erneut senden",
|
||||
"inviteResent": "Einladung erfolgreich erneut gesendet",
|
||||
"inviteCreatedNoEmail": "Neue Einladung erstellt. E-Mail konnte nicht gesendet werden.",
|
||||
"resendFailed": "Einladung konnte nicht erneut gesendet werden"
|
||||
"resendFailed": "Einladung konnte nicht erneut gesendet werden",
|
||||
"myProfile": "Mein Profil",
|
||||
"editUser": "Benutzer bearbeiten",
|
||||
"changePassword": "Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"passwordChanged": "Passwort erfolgreich geändert",
|
||||
"passwordChangeFailed": "Passwort konnte nicht geändert werden",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"regenerateApiKey": "API-Schlüssel neu generieren",
|
||||
"apiKeyRegenerated": "API-Schlüssel neu generiert",
|
||||
"apiKeyRegenerateFailed": "API-Schlüssel konnte nicht neu generiert werden",
|
||||
"apiKeyConfirm": "Sind Sie sicher? Der aktuelle API-Schlüssel wird ungültig.",
|
||||
"profileUpdated": "Profil erfolgreich aktualisiert",
|
||||
"profileUpdateFailed": "Profil konnte nicht aktualisiert werden"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "DNS-Verwaltung",
|
||||
"description": "DNS-Anbieter und Plugins für die Zertifikatsautomatisierung verwalten"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Willkommen",
|
||||
"description": "Ihr Konto hat Passthrough-Zugriff. Sie können Ihre zugewiesenen Dienste direkt erreichen — keine Verwaltungsoberfläche verfügbar.",
|
||||
"noAccessToManagement": "Sie haben keinen Zugriff auf die Verwaltungsoberfläche."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@
|
||||
"settings": "Settings",
|
||||
"system": "System",
|
||||
"email": "Email (SMTP)",
|
||||
"adminAccount": "Admin Account",
|
||||
"accountManagement": "Account Management",
|
||||
"import": "Import",
|
||||
"caddyfile": "Caddyfile",
|
||||
"importNPM": "Import NPM",
|
||||
@@ -618,6 +616,10 @@
|
||||
"role": "Role",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Admin",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Can access permitted proxy hosts only.",
|
||||
"roleAdminDescription": "Full access to all features and settings.",
|
||||
"rolePassthroughDescription": "Proxy access only — no management interface.",
|
||||
"permissionMode": "Permission Mode",
|
||||
"allowAllBlacklist": "Allow All (Blacklist)",
|
||||
"denyAllWhitelist": "Deny All (Whitelist)",
|
||||
@@ -651,7 +653,23 @@
|
||||
"resendInvite": "Resend Invite",
|
||||
"inviteResent": "Invitation resent successfully",
|
||||
"inviteCreatedNoEmail": "New invite created. Email could not be sent.",
|
||||
"resendFailed": "Failed to resend invitation"
|
||||
"resendFailed": "Failed to resend invitation",
|
||||
"myProfile": "My Profile",
|
||||
"editUser": "Edit User",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"apiKey": "API Key",
|
||||
"regenerateApiKey": "Regenerate API Key",
|
||||
"apiKeyRegenerated": "API key regenerated",
|
||||
"apiKeyRegenerateFailed": "Failed to regenerate API key",
|
||||
"apiKeyConfirm": "Are you sure? The current API key will be invalidated.",
|
||||
"profileUpdated": "Profile updated successfully",
|
||||
"profileUpdateFailed": "Failed to update profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1360,5 +1378,10 @@
|
||||
"validationError": "Key configuration validation failed. Check errors below.",
|
||||
"validationFailed": "Validation request failed: {{error}}",
|
||||
"failedToLoadStatus": "Failed to load encryption status. Please refresh the page."
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Welcome",
|
||||
"description": "Your account has passthrough access. You can reach your assigned services directly — no management interface is available.",
|
||||
"noAccessToManagement": "You do not have access to the management interface."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Configuración",
|
||||
"system": "Sistema",
|
||||
"email": "Correo Electrónico (SMTP)",
|
||||
"adminAccount": "Cuenta de Administrador",
|
||||
"accountManagement": "Gestión de Cuentas",
|
||||
"import": "Importar",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Copias de Seguridad",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rol",
|
||||
"roleUser": "Usuario",
|
||||
"roleAdmin": "Administrador",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Solo puede acceder a los hosts proxy permitidos.",
|
||||
"roleAdminDescription": "Acceso completo a todas las funciones y configuraciones.",
|
||||
"rolePassthroughDescription": "Solo acceso proxy — sin interfaz de gestión.",
|
||||
"permissionMode": "Modo de Permisos",
|
||||
"allowAllBlacklist": "Permitir Todo (Lista Negra)",
|
||||
"denyAllWhitelist": "Denegar Todo (Lista Blanca)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Reenviar invitación",
|
||||
"inviteResent": "Invitación reenviada exitosamente",
|
||||
"inviteCreatedNoEmail": "Nueva invitación creada. No se pudo enviar el correo electrónico.",
|
||||
"resendFailed": "Error al reenviar la invitación"
|
||||
"resendFailed": "Error al reenviar la invitación",
|
||||
"myProfile": "Mi Perfil",
|
||||
"editUser": "Editar Usuario",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"currentPassword": "Contraseña Actual",
|
||||
"newPassword": "Nueva Contraseña",
|
||||
"confirmPassword": "Confirmar Contraseña",
|
||||
"passwordChanged": "Contraseña cambiada exitosamente",
|
||||
"passwordChangeFailed": "Error al cambiar la contraseña",
|
||||
"passwordMismatch": "Las contraseñas no coinciden",
|
||||
"apiKey": "Clave API",
|
||||
"regenerateApiKey": "Regenerar Clave API",
|
||||
"apiKeyRegenerated": "Clave API regenerada",
|
||||
"apiKeyRegenerateFailed": "Error al regenerar la clave API",
|
||||
"apiKeyConfirm": "¿Está seguro? La clave API actual será invalidada.",
|
||||
"profileUpdated": "Perfil actualizado exitosamente",
|
||||
"profileUpdateFailed": "Error al actualizar el perfil"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de Control",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "Gestión DNS",
|
||||
"description": "Administrar proveedores DNS y plugins para la automatización de certificados"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Bienvenido",
|
||||
"description": "Su cuenta tiene acceso passthrough. Puede acceder a sus servicios asignados directamente — no hay interfaz de gestión disponible.",
|
||||
"noAccessToManagement": "No tiene acceso a la interfaz de gestión."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Paramètres",
|
||||
"system": "Système",
|
||||
"email": "Email (SMTP)",
|
||||
"adminAccount": "Compte Administrateur",
|
||||
"accountManagement": "Gestion des Comptes",
|
||||
"import": "Importer",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Sauvegardes",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rôle",
|
||||
"roleUser": "Utilisateur",
|
||||
"roleAdmin": "Administrateur",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Peut accéder uniquement aux hôtes proxy autorisés.",
|
||||
"roleAdminDescription": "Accès complet à toutes les fonctionnalités et paramètres.",
|
||||
"rolePassthroughDescription": "Accès proxy uniquement — aucune interface de gestion.",
|
||||
"permissionMode": "Mode de Permission",
|
||||
"allowAllBlacklist": "Tout Autoriser (Liste Noire)",
|
||||
"denyAllWhitelist": "Tout Refuser (Liste Blanche)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Renvoyer l'invitation",
|
||||
"inviteResent": "Invitation renvoyée avec succès",
|
||||
"inviteCreatedNoEmail": "Nouvelle invitation créée. L'e-mail n'a pas pu être envoyé.",
|
||||
"resendFailed": "Échec du renvoi de l'invitation"
|
||||
"resendFailed": "Échec du renvoi de l'invitation",
|
||||
"myProfile": "Mon Profil",
|
||||
"editUser": "Modifier l'utilisateur",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeFailed": "Échec du changement de mot de passe",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"apiKey": "Clé API",
|
||||
"regenerateApiKey": "Régénérer la clé API",
|
||||
"apiKeyRegenerated": "Clé API régénérée",
|
||||
"apiKeyRegenerateFailed": "Échec de la régénération de la clé API",
|
||||
"apiKeyConfirm": "Êtes-vous sûr ? La clé API actuelle sera invalidée.",
|
||||
"profileUpdated": "Profil mis à jour avec succès",
|
||||
"profileUpdateFailed": "Échec de la mise à jour du profil"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "Gestion DNS",
|
||||
"description": "Gérer les fournisseurs DNS et les plugins pour l'automatisation des certificats"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Bienvenue",
|
||||
"description": "Votre compte a un accès passthrough. Vous pouvez accéder directement à vos services assignés — aucune interface de gestion n'est disponible.",
|
||||
"noAccessToManagement": "Vous n'avez pas accès à l'interface de gestion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "设置",
|
||||
"system": "系统",
|
||||
"email": "电子邮件 (SMTP)",
|
||||
"adminAccount": "管理员账户",
|
||||
"accountManagement": "账户管理",
|
||||
"import": "导入",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "备份",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "角色",
|
||||
"roleUser": "用户",
|
||||
"roleAdmin": "管理员",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "只能访问允许的代理主机。",
|
||||
"roleAdminDescription": "完全访问所有功能和设置。",
|
||||
"rolePassthroughDescription": "仅代理访问 — 无管理界面。",
|
||||
"permissionMode": "权限模式",
|
||||
"allowAllBlacklist": "允许所有(黑名单)",
|
||||
"denyAllWhitelist": "拒绝所有(白名单)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "重新发送邀请",
|
||||
"inviteResent": "邀请重新发送成功",
|
||||
"inviteCreatedNoEmail": "新邀请已创建。无法发送电子邮件。",
|
||||
"resendFailed": "重新发送邀请失败"
|
||||
"resendFailed": "重新发送邀请失败",
|
||||
"myProfile": "我的资料",
|
||||
"editUser": "编辑用户",
|
||||
"changePassword": "修改密码",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeFailed": "密码修改失败",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"apiKey": "API密钥",
|
||||
"regenerateApiKey": "重新生成API密钥",
|
||||
"apiKeyRegenerated": "API密钥已重新生成",
|
||||
"apiKeyRegenerateFailed": "重新生成API密钥失败",
|
||||
"apiKeyConfirm": "确定吗?当前的API密钥将失效。",
|
||||
"profileUpdated": "资料更新成功",
|
||||
"profileUpdateFailed": "资料更新失败"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表板",
|
||||
@@ -1020,5 +1038,10 @@
|
||||
"dns": {
|
||||
"title": "DNS 管理",
|
||||
"description": "管理 DNS 提供商和插件以实现证书自动化"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "欢迎",
|
||||
"description": "您的账户拥有 Passthrough 访问权限。您可以直接访问分配给您的服务 — 无管理界面可用。",
|
||||
"noAccessToManagement": "您无权访问管理界面。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Label } from '../components/ui/Label'
|
||||
import { Alert } from '../components/ui/Alert'
|
||||
import { Checkbox } from '../components/ui/Checkbox'
|
||||
import { Skeleton } from '../components/ui/Skeleton'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../api/user'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { RefreshCw, Shield, Mail, User, AlertTriangle, Key } from 'lucide-react'
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
|
||||
import { isValidEmail } from '../utils/validation'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Account() {
|
||||
const { t } = useTranslation()
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Profile State
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null)
|
||||
const [confirmPasswordForUpdate, setConfirmPasswordForUpdate] = useState('')
|
||||
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
|
||||
const [pendingProfileUpdate, setPendingProfileUpdate] = useState<{name: string, email: string} | null>(null)
|
||||
const [previousEmail, setPreviousEmail] = useState('')
|
||||
const [showEmailConfirmModal, setShowEmailConfirmModal] = useState(false)
|
||||
|
||||
// Certificate Email State
|
||||
const [certEmail, setCertEmail] = useState('')
|
||||
const [certEmailValid, setCertEmailValid] = useState<boolean | null>(null)
|
||||
const [useUserEmail, setUseUserEmail] = useState(true)
|
||||
const [certEmailInitialized, setCertEmailInitialized] = useState(false)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { changePassword } = useAuth()
|
||||
|
||||
const { data: profile, isLoading: isLoadingProfile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Initialize profile state
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setName(profile.name)
|
||||
setEmail(profile.email)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Validate profile email
|
||||
useEffect(() => {
|
||||
if (email) {
|
||||
setEmailValid(isValidEmail(email))
|
||||
} else {
|
||||
setEmailValid(null)
|
||||
}
|
||||
}, [email])
|
||||
|
||||
// Initialize cert email state only once, when both settings and profile are loaded
|
||||
useEffect(() => {
|
||||
if (!certEmailInitialized && settings && profile) {
|
||||
const savedEmail = settings['caddy.email']
|
||||
if (savedEmail && savedEmail !== profile.email) {
|
||||
setCertEmail(savedEmail)
|
||||
setUseUserEmail(false)
|
||||
} else {
|
||||
setCertEmail(profile.email)
|
||||
setUseUserEmail(true)
|
||||
}
|
||||
setCertEmailInitialized(true)
|
||||
}
|
||||
}, [settings, profile, certEmailInitialized])
|
||||
|
||||
// Validate cert email
|
||||
useEffect(() => {
|
||||
if (certEmail && !useUserEmail) {
|
||||
setCertEmailValid(isValidEmail(certEmail))
|
||||
} else {
|
||||
setCertEmailValid(null)
|
||||
}
|
||||
}, [certEmail, useUserEmail])
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('account.profileUpdated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.profileUpdateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: (variables: { key: string; value: string; category: string }) =>
|
||||
updateSetting(variables.key, variables.value, variables.category),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success(t('account.certEmailUpdated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.certEmailUpdateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('account.apiKeyRegenerated'))
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('account.apiKeyRegenerateFailed', { error: error.message }))
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!emailValid) return
|
||||
|
||||
// Check if email changed
|
||||
if (email !== profile?.email) {
|
||||
setPreviousEmail(profile?.email || '')
|
||||
setPendingProfileUpdate({ name, email })
|
||||
setShowPasswordPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateProfileMutation.mutate({ name, email })
|
||||
}
|
||||
|
||||
const handlePasswordPromptSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pendingProfileUpdate) return
|
||||
|
||||
setShowPasswordPrompt(false)
|
||||
|
||||
// If email changed, we might need to ask about cert email too
|
||||
// But first, let's update the profile with the password
|
||||
updateProfileMutation.mutate({
|
||||
name: pendingProfileUpdate.name,
|
||||
email: pendingProfileUpdate.email,
|
||||
current_password: confirmPasswordForUpdate
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
// Check if we need to prompt for cert email
|
||||
// We do this AFTER success to ensure profile is updated
|
||||
// But wait, if we do it after success, the profile email is already new.
|
||||
// The user wanted to be asked.
|
||||
// Let's ask about cert email first? No, user said "Updateing email test the popup worked as expected"
|
||||
// But "I chose to keep my certificate email as the old email and it changed anyway"
|
||||
// This implies the logic below is flawed or the backend/frontend sync is weird.
|
||||
|
||||
// Let's show the cert email modal if the update was successful AND it was an email change
|
||||
setShowEmailConfirmModal(true)
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmPasswordForUpdate('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmEmailUpdate = (updateCertEmail: boolean) => {
|
||||
setShowEmailConfirmModal(false)
|
||||
|
||||
if (updateCertEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: email,
|
||||
category: 'caddy'
|
||||
})
|
||||
setCertEmail(email)
|
||||
setUseUserEmail(true)
|
||||
} else {
|
||||
// If user chose NO, we must ensure the cert email stays as the OLD email.
|
||||
// If settings['caddy.email'] is empty, it defaults to profile email (which is now NEW).
|
||||
// So we must explicitly save the OLD email.
|
||||
const savedEmail = settings?.['caddy.email']
|
||||
if (!savedEmail && previousEmail) {
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: previousEmail,
|
||||
category: 'caddy'
|
||||
})
|
||||
// Update local state immediately
|
||||
setCertEmail(previousEmail)
|
||||
setUseUserEmail(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateCertEmail = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!useUserEmail && !certEmailValid) return
|
||||
|
||||
const emailToSave = useUserEmail ? profile?.email : certEmail
|
||||
if (!emailToSave) return
|
||||
|
||||
updateSettingMutation.mutate({
|
||||
key: 'caddy.email',
|
||||
value: emailToSave,
|
||||
category: 'caddy'
|
||||
})
|
||||
}
|
||||
|
||||
// Compute disabled state for certificate email button
|
||||
// Button should be disabled when using custom email and it's invalid/empty const isCertEmailButtonDisabled = useUserEmail ? false : (certEmailValid !== true)
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t('account.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await changePassword(oldPassword, newPassword)
|
||||
toast.success(t('account.passwordUpdated'))
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
toast.error(error.message || t('account.passwordUpdateFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-500/10 rounded-lg">
|
||||
<User className="h-6 w-6 text-brand-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content-primary">{t('account.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-brand-500" />
|
||||
<CardTitle>{t('account.profile')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t('account.profileDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name" required>{t('common.name')}</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-email" required>{t('auth.email')}</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
error={emailValid === false ? t('errors.invalidEmail') : undefined}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={updateProfileMutation.isPending} disabled={emailValid === false}>
|
||||
{t('account.saveProfile')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Certificate Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-info" />
|
||||
<CardTitle>{t('account.certificateEmail')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.certificateEmailDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleUpdateCertEmail}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="useUserEmail"
|
||||
checked={useUserEmail}
|
||||
onCheckedChange={(checked) => {
|
||||
setUseUserEmail(checked === true)
|
||||
if (checked && profile) {
|
||||
setCertEmail(profile.email)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="useUserEmail" className="cursor-pointer">
|
||||
{t('account.useAccountEmail', { email: profile?.email })}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!useUserEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert-email" required>{t('account.customEmail')}</Label>
|
||||
<Input
|
||||
id="cert-email"
|
||||
type="email"
|
||||
value={certEmail}
|
||||
onChange={(e) => setCertEmail(e.target.value)}
|
||||
required={!useUserEmail}
|
||||
error={certEmailValid === false ? t('errors.invalidEmail') : undefined}
|
||||
errorTestId="cert-email-error"
|
||||
aria-invalid={certEmailValid === false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateSettingMutation.isPending}
|
||||
disabled={useUserEmail ? false : certEmailValid !== true}
|
||||
data-use-user-email={useUserEmail}
|
||||
data-cert-email-valid={String(certEmailValid)}
|
||||
data-compute-disabled={String(useUserEmail ? false : certEmailValid !== true)}
|
||||
>
|
||||
{t('account.saveCertificateEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-success" />
|
||||
<CardTitle>{t('account.changePassword')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t('account.changePasswordDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordChange}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password" required>{t('account.currentPassword')}</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password" required>{t('account.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" required>{t('account.confirmNewPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
error={confirmPassword && newPassword !== confirmPassword ? t('account.passwordsDoNotMatch') : undefined}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" isLoading={loading}>
|
||||
{t('account.updatePassword')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* API Key */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-warning" />
|
||||
<CardTitle>{t('account.apiKey')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.apiKeyDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={profile?.api_key_masked || ''}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
isLoading={regenerateMutation.isPending}
|
||||
title={t('account.regenerateApiKey')}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert variant="warning" title={t('account.securityNotice')}>
|
||||
{t('account.securityNoticeMessage')}
|
||||
</Alert>
|
||||
|
||||
{/* Password Prompt Modal */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 text-brand-500">
|
||||
<Shield className="h-6 w-6" />
|
||||
<CardTitle>{t('account.confirmPassword')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.confirmPasswordDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handlePasswordPromptSubmit}>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-current-password" required>{t('account.currentPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-current-password"
|
||||
type="password"
|
||||
placeholder={t('account.enterPassword')}
|
||||
value={confirmPasswordForUpdate}
|
||||
onChange={(e) => setConfirmPasswordForUpdate(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-3">
|
||||
<Button type="submit" className="w-full" isLoading={updateProfileMutation.isPending}>
|
||||
{t('account.confirmAndUpdate')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowPasswordPrompt(false)
|
||||
setConfirmPasswordForUpdate('')
|
||||
setPendingProfileUpdate(null)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Update Confirmation Modal */}
|
||||
{showEmailConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 text-warning">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
<CardTitle>{t('account.updateCertEmailTitle')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('account.updateCertEmailDescription', { email })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col gap-3">
|
||||
<Button onClick={() => confirmEmailUpdate(true)} className="w-full">
|
||||
{t('account.yesUpdateCertEmail')}
|
||||
</Button>
|
||||
<Button onClick={() => confirmEmailUpdate(false)} variant="secondary" className="w-full">
|
||||
{t('account.noKeepEmail', { email: previousEmail || certEmail })}
|
||||
</Button>
|
||||
<Button onClick={() => setShowEmailConfirmModal(false)} variant="ghost" className="w-full">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
frontend/src/pages/PassthroughLanding.tsx
Normal file
54
frontend/src/pages/PassthroughLanding.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Shield, LogOut } from 'lucide-react'
|
||||
|
||||
export default function PassthroughLanding() {
|
||||
const { t } = useTranslation()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-bg dark:bg-dark-bg flex items-center justify-center p-4">
|
||||
<main className="w-full max-w-md" aria-labelledby="passthrough-heading">
|
||||
<Card className="p-8 text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 bg-blue-900/20 rounded-full">
|
||||
<Shield className="h-8 w-8 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 id="passthrough-heading" className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('passthrough.title')}
|
||||
</h1>
|
||||
{user?.name && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{user.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-content-secondary">
|
||||
{t('passthrough.description')}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-content-muted">
|
||||
{t('passthrough.noAccessToManagement')}
|
||||
</p>
|
||||
|
||||
<nav aria-label={t('passthrough.title')}>
|
||||
<Button
|
||||
onClick={logout}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t('auth.logout')}
|
||||
</Button>
|
||||
</nav>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PageShell } from '../components/layout/PageShell'
|
||||
import { cn } from '../utils/cn'
|
||||
import { Settings as SettingsIcon, Server, Mail, User, Bell } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Server, Mail, Bell, Users } from 'lucide-react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { user } = useAuth()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
@@ -14,7 +16,7 @@ export default function Settings() {
|
||||
{ path: '/settings/system', label: t('settings.system'), icon: Server },
|
||||
{ path: '/settings/notifications', label: t('navigation.notifications'), icon: Bell },
|
||||
{ path: '/settings/smtp', label: t('settings.smtp'), icon: Mail },
|
||||
{ path: '/settings/account', label: t('settings.account'), icon: User },
|
||||
...(user?.role === 'admin' ? [{ path: '/settings/users', label: t('navigation.users'), icon: Users }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
@@ -17,10 +17,14 @@ import {
|
||||
updateUser,
|
||||
updateUserPermissions,
|
||||
resendInvite,
|
||||
getProfile,
|
||||
updateProfile,
|
||||
regenerateApiKey,
|
||||
} from '../api/users'
|
||||
import type { User, InviteUserRequest, PermissionMode, UpdateUserPermissionsRequest } from '../api/users'
|
||||
import { getProxyHosts } from '../api/proxyHosts'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
@@ -36,6 +40,10 @@ import {
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Pencil,
|
||||
Key,
|
||||
Lock,
|
||||
UserCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface InviteModalProps {
|
||||
@@ -49,7 +57,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState<string | null>(null)
|
||||
const [role, setRole] = useState<'user' | 'admin'>('user')
|
||||
const [role, setRole] = useState<'user' | 'admin' | 'passthrough'>('user')
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
const [inviteResult, setInviteResult] = useState<{
|
||||
@@ -170,7 +178,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const handleClose = () => {
|
||||
setEmail('')
|
||||
setEmailError(null)
|
||||
setRole('user')
|
||||
setRole('user' as 'user' | 'admin' | 'passthrough')
|
||||
setPermissionMode('allow_all')
|
||||
setSelectedHosts([])
|
||||
setInviteResult(null)
|
||||
@@ -287,15 +295,21 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
<select
|
||||
id="invite-user-role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin' | 'passthrough')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
<option value="passthrough">{t('users.rolePassthrough')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{role === 'admin' && t('users.roleAdminDescription')}
|
||||
{role === 'user' && t('users.roleUserDescription')}
|
||||
{role === 'passthrough' && t('users.rolePassthroughDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{role === 'user' && (
|
||||
{(role === 'user' || role === 'passthrough') && (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<label htmlFor="invite-permission-mode" className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
@@ -566,12 +580,278 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
)
|
||||
}
|
||||
|
||||
interface UserDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: User | null
|
||||
isSelf: boolean
|
||||
}
|
||||
|
||||
function UserDetailModal({ isOpen, onClose, user, isSelf }: UserDetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { changePassword } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPasswordSection, setShowPasswordSection] = useState(false)
|
||||
const [apiKeyMasked, setApiKeyMasked] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || '')
|
||||
setEmail(user.email || '')
|
||||
setShowPasswordSection(false)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Fetch profile for API key info when editing self
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: getProfile,
|
||||
enabled: isOpen && isSelf,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setApiKeyMasked(profile.api_key_masked || '')
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
// Focus trap and Escape handling
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
// Focus trap
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus first focusable element on open
|
||||
requestAnimationFrame(() => {
|
||||
const first = dialogRef.current?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
first?.focus()
|
||||
})
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const profileMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isSelf) {
|
||||
return updateProfile({ name, email })
|
||||
}
|
||||
return updateUser(user!.id, { name, email })
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
if (isSelf) queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success(t('users.profileUpdated'))
|
||||
onClose()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.profileUpdateFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new Error(t('users.passwordMismatch'))
|
||||
}
|
||||
return changePassword(currentPassword, newPassword)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('users.passwordChanged'))
|
||||
setShowPasswordSection(false)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { message?: string }
|
||||
toast.error(err.message || t('users.passwordChangeFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
const regenApiKeyMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
setApiKeyMasked(data.api_key_masked)
|
||||
toast.success(t('users.apiKeyRegenerated'))
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } }
|
||||
toast.error(err.response?.data?.error || t('users.apiKeyRegenerateFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
if (!isOpen || !user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-detail-modal-title"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 id="user-detail-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Pencil className="h-5 w-5" />
|
||||
{isSelf ? t('users.myProfile') : t('users.editUser')}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white" aria-label={t('common.close')}>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Name & Email */}
|
||||
<div>
|
||||
<Input
|
||||
label={t('common.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label={t('users.emailAddress')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
onClick={() => profileMutation.mutate()}
|
||||
isLoading={profileMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Password Section — self only */}
|
||||
{isSelf && (
|
||||
<div className="border-t border-gray-700 pt-4">
|
||||
<button
|
||||
onClick={() => setShowPasswordSection(!showPasswordSection)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-300 hover:text-white"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{t('users.changePassword')}
|
||||
</button>
|
||||
{showPasswordSection && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<Input
|
||||
label={t('users.currentPassword')}
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('users.newPassword')}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('users.confirmPassword')}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{newPassword && confirmPassword && newPassword !== confirmPassword && (
|
||||
<p className="text-xs text-red-400" role="alert">{t('users.passwordMismatch')}</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => passwordMutation.mutate()}
|
||||
isLoading={passwordMutation.isPending}
|
||||
disabled={!currentPassword || !newPassword || newPassword !== confirmPassword}
|
||||
variant="secondary"
|
||||
>
|
||||
{t('users.changePassword')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Section — self only */}
|
||||
{isSelf && (
|
||||
<div className="border-t border-gray-700 pt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-300">{t('users.apiKey')}</span>
|
||||
</div>
|
||||
{apiKeyMasked && (
|
||||
<p className="text-sm font-mono text-gray-500 mb-2">{apiKeyMasked}</p>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (confirm(t('users.apiKeyConfirm'))) {
|
||||
regenApiKeyMutation.mutate()
|
||||
}
|
||||
}}
|
||||
isLoading={regenApiKeyMutation.isPending}
|
||||
>
|
||||
{t('users.regenerateApiKey')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user: authUser } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||
const [detailUser, setDetailUser] = useState<User | null>(null)
|
||||
const [isSelfEdit, setIsSelfEdit] = useState(false)
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
@@ -630,6 +910,14 @@ export default function UsersPage() {
|
||||
setPermissionsModalOpen(true)
|
||||
}
|
||||
|
||||
const openDetail = (user: User, self: boolean) => {
|
||||
setDetailUser(user)
|
||||
setIsSelfEdit(self)
|
||||
setDetailModalOpen(true)
|
||||
}
|
||||
|
||||
const currentUser = users?.find((u) => u.id === authUser?.user_id)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -651,6 +939,26 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* My Profile Card */}
|
||||
{currentUser && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCircle className="h-10 w-10 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">{t('users.myProfile')}</h2>
|
||||
<p className="text-sm text-white">{currentUser.name || t('users.noName')}</p>
|
||||
<p className="text-xs text-gray-500">{currentUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => openDetail(currentUser, true)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
{t('users.editUser')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -678,10 +986,14 @@ export default function UsersPage() {
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-900/30 text-purple-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
: user.role === 'passthrough'
|
||||
? 'bg-gray-900/30 text-gray-400'
|
||||
: 'bg-blue-900/30 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
{user.role === 'admin' && t('users.roleAdmin')}
|
||||
{user.role === 'user' && t('users.roleUser')}
|
||||
{user.role === 'passthrough' && t('users.rolePassthrough')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
@@ -721,6 +1033,14 @@ export default function UsersPage() {
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openDetail(user, user.id === authUser?.user_id)}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded"
|
||||
title={t('users.editUser')}
|
||||
aria-label={t('users.editUser')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{user.invite_status === 'pending' && (
|
||||
<button
|
||||
onClick={() => resendInviteMutation.mutate(user.id)}
|
||||
@@ -779,6 +1099,16 @@ export default function UsersPage() {
|
||||
user={selectedUser}
|
||||
proxyHosts={proxyHosts}
|
||||
/>
|
||||
|
||||
<UserDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false)
|
||||
setDetailUser(null)
|
||||
}}
|
||||
user={detailUser}
|
||||
isSelf={isSelfEdit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const translations: Record<string, string> = {
|
||||
'settings.system': 'System',
|
||||
'navigation.notifications': 'Notifications',
|
||||
'settings.smtp': 'Email (SMTP)',
|
||||
'settings.account': 'Account',
|
||||
'navigation.users': 'Users',
|
||||
}
|
||||
|
||||
const t = (key: string) => translations[key] ?? key
|
||||
@@ -19,6 +19,10 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t }),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({ user: { user_id: 1, role: 'admin', name: 'Admin' } }),
|
||||
}))
|
||||
|
||||
const renderWithRoute = (route: string) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
@@ -27,7 +31,7 @@ const renderWithRoute = (route: string) =>
|
||||
<Route path="system" element={<div>System Page</div>} />
|
||||
<Route path="notifications" element={<div>Notifications Page</div>} />
|
||||
<Route path="smtp" element={<div>SMTP Page</div>} />
|
||||
<Route path="account" element={<div>Account Page</div>} />
|
||||
<Route path="users" element={<div>Users Page</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -46,12 +50,12 @@ describe('Settings page', () => {
|
||||
expect(screen.getByText('System Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps navigation order consistent', () => {
|
||||
it('keeps navigation order consistent for admin', () => {
|
||||
renderWithRoute('/settings/notifications')
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
const labels = links.map(link => link.textContent)
|
||||
|
||||
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Account'])
|
||||
expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Users'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,20 @@ vi.mock('../../api/users', () => ({
|
||||
acceptInvite: vi.fn(),
|
||||
previewInviteURL: vi.fn(),
|
||||
resendInvite: vi.fn(),
|
||||
getProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
regenerateApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: vi.fn().mockReturnValue({
|
||||
user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
|
||||
changePassword: vi.fn().mockResolvedValue(undefined),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
@@ -78,6 +92,18 @@ const mockUsers = [
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: '999-000',
|
||||
email: 'passthrough@example.com',
|
||||
name: 'Passthrough User',
|
||||
role: 'passthrough' as const,
|
||||
enabled: true,
|
||||
invite_status: 'accepted' as const,
|
||||
permission_mode: 'allow_all' as const,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const mockProxyHosts = [
|
||||
@@ -127,8 +153,8 @@ describe('UsersPage', () => {
|
||||
expect(screen.getByText('User Management')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Admin User')).toBeTruthy()
|
||||
expect(screen.getByText('admin@example.com')).toBeTruthy()
|
||||
expect(screen.getAllByText('Admin User').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('admin@example.com').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Regular User')).toBeTruthy()
|
||||
expect(screen.getByText('user@example.com')).toBeTruthy()
|
||||
})
|
||||
@@ -346,6 +372,58 @@ describe('UsersPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders passthrough role badge', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Passthrough')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders My Profile card for current user', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('My Profile')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows passthrough option in invite role select', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await waitFor(() => expect(screen.getByText('Invite User')).toBeTruthy())
|
||||
await user.click(screen.getByRole('button', { name: /Invite User/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const roleSelect = screen.getByLabelText('Role') as HTMLSelectElement
|
||||
const options = Array.from(roleSelect.options).map(o => o.value)
|
||||
expect(options).toContain('passthrough')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens detail modal when edit button is clicked', async () => {
|
||||
vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
|
||||
|
||||
renderWithQueryClient(<UsersPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Regular User')).toBeTruthy())
|
||||
|
||||
const user = userEvent.setup()
|
||||
const editButtons = screen.getAllByTitle('Edit User')
|
||||
await user.click(editButtons[1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: /Edit User/i })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Preview in InviteModal', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
|
||||
Reference in New Issue
Block a user