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:
GitHub Actions
2026-03-03 02:17:17 +00:00
parent 3632d0d88c
commit a681d6aa30
19 changed files with 708 additions and 630 deletions

View File

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

View File

@@ -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: {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
)}

View 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

View File

@@ -2,7 +2,7 @@ import { createContext } from 'react';
export interface User {
user_id: number;
role: string;
role: 'admin' | 'user' | 'passthrough';
name?: string;
email?: string;
}

View File

@@ -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 />

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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": "您无权访问管理界面。"
}
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 (

View File

@@ -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>
)
}

View File

@@ -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'])
})
})

View File

@@ -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()