diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ddcc4dbd..343225da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + + + + } /> @@ -88,7 +94,9 @@ export default function App() { } /> } /> } /> - } /> + + {/* Legacy redirects for old user management paths */} + } /> } /> } /> @@ -99,8 +107,10 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + {/* Legacy redirects */} + } /> + } /> {/* Tasks Routes */} diff --git a/frontend/src/api/__tests__/user.test.ts b/frontend/src/api/__tests__/user.test.ts index ee43f501..167f523b 100644 --- a/frontend/src/api/__tests__/user.test.ts +++ b/frontend/src/api/__tests__/user.test.ts @@ -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: { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts deleted file mode 100644 index 0477d6c5..00000000 --- a/frontend/src/api/user.ts +++ /dev/null @@ -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 => { - 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 => { - const response = await client.post('/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 -} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index e9aebc27..6fc67a80 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -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 => { const response = await client.post(`/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 => { + 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 => { + const response = await client.post('/user/api-key') + return response.data +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 1e83b1f5..b19de38b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) {
{user && ( - + {user.name} )} diff --git a/frontend/src/components/RequireRole.tsx b/frontend/src/components/RequireRole.tsx new file mode 100644 index 00000000..0ab14b85 --- /dev/null +++ b/frontend/src/components/RequireRole.tsx @@ -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 = ({ allowed, children }) => { + const { user } = useAuth() + + if (!user) { + return + } + + if (!allowed.includes(user.role)) { + const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/' + return + } + + return children +} + +export default RequireRole diff --git a/frontend/src/context/AuthContextValue.ts b/frontend/src/context/AuthContextValue.ts index ebd5c09e..4644a810 100644 --- a/frontend/src/context/AuthContextValue.ts +++ b/frontend/src/context/AuthContextValue.ts @@ -2,7 +2,7 @@ import { createContext } from 'react'; export interface User { user_id: number; - role: string; + role: 'admin' | 'user' | 'passthrough'; name?: string; email?: string; } diff --git a/frontend/src/hooks/__tests__/useAuth.test.tsx b/frontend/src/hooks/__tests__/useAuth.test.tsx index 00808d90..e3eb5dd8 100644 --- a/frontend/src/hooks/__tests__/useAuth.test.tsx +++ b/frontend/src/hooks/__tests__/useAuth.test.tsx @@ -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( diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index e40b3da1..55259dd7 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -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." } } diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 04eca004..71d9d742 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -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." } } diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index a9067bbe..0271f797 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -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." } } diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index 525cec3f..73512630 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -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." } } diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index 885d64b9..e2c1bf77 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -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": "您无权访问管理界面。" } } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx deleted file mode 100644 index 571dde00..00000000 --- a/frontend/src/pages/Account.tsx +++ /dev/null @@ -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(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(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 ( -
- - {[1, 2, 3, 4].map((i) => ( - - - - - - - - ))} -
- ) - } - - return ( -
-
-
- -
-

{t('account.title')}

-
- - {/* Profile Settings */} - - -
- - {t('account.profile')} -
- {t('account.profileDescription')} -
-
- -
- - setName(e.target.value)} - required - /> -
-
- - setEmail(e.target.value)} - required - error={emailValid === false ? t('errors.invalidEmail') : undefined} - /> -
-
- - - -
-
- - {/* Certificate Email Settings */} - - -
- - {t('account.certificateEmail')} -
- - {t('account.certificateEmailDescription')} - -
-
- -
- { - setUseUserEmail(checked === true) - if (checked && profile) { - setCertEmail(profile.email) - } - }} - /> - -
- - {!useUserEmail && ( -
- - setCertEmail(e.target.value)} - required={!useUserEmail} - error={certEmailValid === false ? t('errors.invalidEmail') : undefined} - errorTestId="cert-email-error" - aria-invalid={certEmailValid === false} - /> -
- )} -
- - - -
-
- - {/* Password Change */} - - -
- - {t('account.changePassword')} -
- {t('account.changePasswordDescription')} -
-
- -
- - setOldPassword(e.target.value)} - required - autoComplete="current-password" - /> -
-
- - setNewPassword(e.target.value)} - required - autoComplete="new-password" - /> - -
-
- - setConfirmPassword(e.target.value)} - required - error={confirmPassword && newPassword !== confirmPassword ? t('account.passwordsDoNotMatch') : undefined} - autoComplete="new-password" - /> -
-
- - - -
-
- - {/* API Key */} - - -
- - {t('account.apiKey')} -
- - {t('account.apiKeyDescription')} - -
- -
- - -
-
-
- - - {t('account.securityNoticeMessage')} - - - {/* Password Prompt Modal */} - {showPasswordPrompt && ( -
- - -
- - {t('account.confirmPassword')} -
- - {t('account.confirmPasswordDescription')} - -
-
- -
- - setConfirmPasswordForUpdate(e.target.value)} - required - autoFocus - /> -
-
- - - - -
-
-
- )} - - {/* Email Update Confirmation Modal */} - {showEmailConfirmModal && ( -
- - -
- - {t('account.updateCertEmailTitle')} -
- - {t('account.updateCertEmailDescription', { email })} - -
- - - - - -
-
- )} -
- ) -} diff --git a/frontend/src/pages/PassthroughLanding.tsx b/frontend/src/pages/PassthroughLanding.tsx new file mode 100644 index 00000000..33a619c3 --- /dev/null +++ b/frontend/src/pages/PassthroughLanding.tsx @@ -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 ( +
+
+ +
+
+
+
+ +
+

+ {t('passthrough.title')} +

+ {user?.name && ( +

+ {user.name} +

+ )} +
+ +

+ {t('passthrough.description')} +

+ +

+ {t('passthrough.noAccessToManagement')} +

+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 973438ab..2b591b4e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 ( diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index adfdc4fc..714f07f9 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -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(null) - const [role, setRole] = useState<'user' | 'admin'>('user') + const [role, setRole] = useState<'user' | 'admin' | 'passthrough'>('user') const [permissionMode, setPermissionMode] = useState('allow_all') const [selectedHosts, setSelectedHosts] = useState([]) 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) { +

+ {role === 'admin' && t('users.roleAdminDescription')} + {role === 'user' && t('users.roleUserDescription')} + {role === 'passthrough' && t('users.rolePassthroughDescription')} +

- {role === 'user' && ( + {(role === 'user' || role === 'passthrough') && ( <>