{icon && (
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx
index 9ee84e59..b33e3ee5 100644
--- a/frontend/src/components/ui/Input.tsx
+++ b/frontend/src/components/ui/Input.tsx
@@ -50,6 +50,7 @@ const Input = React.forwardRef(
ref={ref}
type={isPassword ? (showPassword ? 'text' : 'password') : type}
disabled={disabled}
+ aria-describedby={error && errorTestId ? errorTestId : undefined}
className={cn(
'flex h-10 w-full rounded-lg px-4 py-2',
'bg-surface-base border text-content-primary',
@@ -93,6 +94,7 @@ const Input = React.forwardRef(
{error && (
{
+ it('renders tabs container with proper role', () => {
+ render(
+
+
+ Tab 1
+
+
+ )
+
+ const tablist = screen.getByRole('tablist')
+ expect(tablist).toBeInTheDocument()
+ })
+
+ it('renders all tabs with correct labels', () => {
+ render(
+
+
+ First Tab
+ Second Tab
+ Third Tab
+
+
+ )
+
+ expect(screen.getByRole('tab', { name: 'First Tab' })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: 'Second Tab' })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: 'Third Tab' })).toBeInTheDocument()
+ })
+
+ it('first tab is active by default', () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+
+ )
+
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+
+ expect(tab1).toHaveAttribute('data-state', 'active')
+ expect(tab2).toHaveAttribute('data-state', 'inactive')
+ })
+
+ it('clicking tab changes active state', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+
+
+ )
+
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+ await user.click(tab2)
+
+ expect(tab2).toHaveAttribute('data-state', 'active')
+ })
+
+ it('only one tab active at a time', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+ )
+
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+ const tab3 = screen.getByRole('tab', { name: 'Tab 3' })
+
+ // Initially tab1 is active
+ expect(tab1).toHaveAttribute('data-state', 'active')
+
+ // Click tab2
+ await user.click(tab2)
+ expect(tab2).toHaveAttribute('data-state', 'active')
+ expect(tab1).toHaveAttribute('data-state', 'inactive')
+ expect(tab3).toHaveAttribute('data-state', 'inactive')
+
+ // Click tab3
+ await user.click(tab3)
+ expect(tab3).toHaveAttribute('data-state', 'active')
+ expect(tab1).toHaveAttribute('data-state', 'inactive')
+ expect(tab2).toHaveAttribute('data-state', 'inactive')
+ })
+
+ it('disabled tab cannot be clicked', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+
+
+ )
+
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+
+ expect(tab2).toBeDisabled()
+ await user.click(tab2)
+
+ // Tab 1 should still be active
+ expect(tab1).toHaveAttribute('data-state', 'active')
+ expect(tab2).toHaveAttribute('data-state', 'inactive')
+ })
+
+ it('keyboard navigation with arrow keys', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+ )
+
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+
+ tab1.focus()
+ expect(tab1).toHaveFocus()
+
+ // Arrow right should move focus and activate tab2
+ await user.keyboard('{ArrowRight}')
+ expect(tab2).toHaveFocus()
+ expect(tab2).toHaveAttribute('data-state', 'active')
+ })
+
+ it('active tab has correct aria-selected', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+
+
+ )
+
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' })
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+
+ expect(tab1).toHaveAttribute('aria-selected', 'true')
+ expect(tab2).toHaveAttribute('aria-selected', 'false')
+
+ await user.click(tab2)
+
+ expect(tab1).toHaveAttribute('aria-selected', 'false')
+ expect(tab2).toHaveAttribute('aria-selected', 'true')
+ })
+
+ it('tab panels show/hide based on active tab', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+
+ )
+
+ // Content 1 should be visible (active)
+ const content1 = screen.getByTestId('content1')
+ expect(content1).toBeInTheDocument()
+ expect(content1).toHaveAttribute('data-state', 'active')
+
+ // Content 2 should be hidden (inactive)
+ const content2 = screen.getByTestId('content2')
+ expect(content2).toBeInTheDocument()
+ expect(content2).toHaveAttribute('data-state', 'inactive')
+
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' })
+ await user.click(tab2)
+
+ // After click, content states should swap
+ expect(content1).toHaveAttribute('data-state', 'inactive')
+ expect(content2).toHaveAttribute('data-state', 'active')
+ })
+
+ it('custom className is applied', () => {
+ render(
+
+
+ Tab 1
+
+ Content
+
+ )
+
+ const tablist = screen.getByRole('tablist')
+ const tab = screen.getByRole('tab', { name: 'Tab 1' })
+ const content = screen.getByText('Content')
+
+ expect(tablist).toHaveClass('custom-list-class')
+ expect(tab).toHaveClass('custom-trigger-class')
+ expect(content).toHaveClass('custom-content-class')
+ })
+})
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index a0cd3058..1c77b478 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -1,11 +1,28 @@
-import { useState, useEffect, type ReactNode, type FC } from 'react';
-import client, { setAuthToken } from '../api/client';
+import { useState, useEffect, useCallback, type ReactNode, type FC } from 'react';
+import client, { setAuthToken, setAuthErrorHandler } from '../api/client';
import { AuthContext, User } from './AuthContextValue';
export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
+ // Handle session expiry by clearing auth state and redirecting to login
+ const handleAuthError = useCallback(() => {
+ console.log('Session expired, redirecting to login');
+ localStorage.removeItem('charon_auth_token');
+ setAuthToken(null);
+ setUser(null);
+ // Use window.location for full page redirect to clear any stale state
+ if (window.location.pathname !== '/login') {
+ window.location.href = '/login';
+ }
+ }, []);
+
+ // Register auth error handler on mount
+ useEffect(() => {
+ setAuthErrorHandler(handleAuthError);
+ }, [handleAuthError]);
+
useEffect(() => {
const checkAuth = async () => {
try {
@@ -54,10 +71,16 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
};
const changePassword = async (oldPassword: string, newPassword: string) => {
- await client.post('/auth/change-password', {
- old_password: oldPassword,
- new_password: newPassword,
- });
+ try {
+ await client.post('/auth/change-password', {
+ old_password: oldPassword,
+ new_password: newPassword,
+ });
+ } catch (error: any) {
+ // Extract error message from API response
+ const message = error.response?.data?.error || error.message || 'Password change failed';
+ throw new Error(message);
+ }
};
// Auto-logout logic
diff --git a/frontend/src/hooks/__tests__/useAuditLogs.test.tsx b/frontend/src/hooks/__tests__/useAuditLogs.test.tsx
new file mode 100644
index 00000000..19fb9b0d
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useAuditLogs.test.tsx
@@ -0,0 +1,136 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { useAuditLogs, useAuditLog, useAuditLogsByProvider } from '../useAuditLogs'
+
+// Mock the API module
+vi.mock('../../api/auditLogs', () => ({
+ getAuditLogs: vi.fn(),
+ getAuditLog: vi.fn(),
+ getAuditLogsByProvider: vi.fn(),
+}))
+
+import { getAuditLogs, getAuditLog, getAuditLogsByProvider } from '../../api/auditLogs'
+
+const mockAuditLog = {
+ id: 1,
+ uuid: 'test-uuid-123',
+ actor: 'admin@test.com',
+ action: 'dns_provider_create' as const,
+ event_category: 'dns_provider' as const,
+ resource_id: 1,
+ details: 'Created DNS provider',
+ ip_address: '127.0.0.1',
+ created_at: '2026-01-24T12:00:00Z',
+}
+
+const mockListResponse = {
+ logs: [mockAuditLog],
+ total: 1,
+ page: 1,
+ limit: 50,
+}
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ })
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ )
+}
+
+describe('useAuditLogs hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('fetches audit logs with default parameters', async () => {
+ vi.mocked(getAuditLogs).mockResolvedValue(mockListResponse)
+
+ const { result } = renderHook(() => useAuditLogs(), { wrapper: createWrapper() })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getAuditLogs).toHaveBeenCalledWith(undefined, 1, 50)
+ expect(result.current.data).toEqual(mockListResponse)
+ })
+
+ it('fetches audit logs with filters', async () => {
+ vi.mocked(getAuditLogs).mockResolvedValue(mockListResponse)
+
+ const filters = { event_category: 'dns_provider' as const }
+ const { result } = renderHook(() => useAuditLogs(filters, 2, 25), { wrapper: createWrapper() })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getAuditLogs).toHaveBeenCalledWith(filters, 2, 25)
+ })
+})
+
+describe('useAuditLog hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('fetches a single audit log by UUID', async () => {
+ vi.mocked(getAuditLog).mockResolvedValue(mockAuditLog)
+
+ const { result } = renderHook(() => useAuditLog('test-uuid-123'), { wrapper: createWrapper() })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getAuditLog).toHaveBeenCalledWith('test-uuid-123')
+ expect(result.current.data).toEqual(mockAuditLog)
+ })
+
+ it('does not fetch when uuid is null', () => {
+ const { result } = renderHook(() => useAuditLog(null), { wrapper: createWrapper() })
+
+ expect(result.current.fetchStatus).toBe('idle')
+ expect(getAuditLog).not.toHaveBeenCalled()
+ })
+})
+
+describe('useAuditLogsByProvider hook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('fetches audit logs for a provider', async () => {
+ vi.mocked(getAuditLogsByProvider).mockResolvedValue(mockListResponse)
+
+ const { result } = renderHook(() => useAuditLogsByProvider(123), { wrapper: createWrapper() })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getAuditLogsByProvider).toHaveBeenCalledWith(123, 1, 50)
+ expect(result.current.data).toEqual(mockListResponse)
+ })
+
+ it('does not fetch when providerId is null', () => {
+ const { result } = renderHook(() => useAuditLogsByProvider(null), { wrapper: createWrapper() })
+
+ expect(result.current.fetchStatus).toBe('idle')
+ expect(getAuditLogsByProvider).not.toHaveBeenCalled()
+ })
+
+ it('does not fetch when providerId is 0', () => {
+ const { result } = renderHook(() => useAuditLogsByProvider(0), { wrapper: createWrapper() })
+
+ expect(result.current.fetchStatus).toBe('idle')
+ expect(getAuditLogsByProvider).not.toHaveBeenCalled()
+ })
+
+ it('fetches with custom pagination', async () => {
+ vi.mocked(getAuditLogsByProvider).mockResolvedValue(mockListResponse)
+
+ const { result } = renderHook(() => useAuditLogsByProvider(456, 3, 100), { wrapper: createWrapper() })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getAuditLogsByProvider).toHaveBeenCalledWith(456, 3, 100)
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useCredentials.test.tsx b/frontend/src/hooks/__tests__/useCredentials.test.tsx
index 32e8d881..5c970093 100644
--- a/frontend/src/hooks/__tests__/useCredentials.test.tsx
+++ b/frontend/src/hooks/__tests__/useCredentials.test.tsx
@@ -37,8 +37,8 @@ describe('useCredentials', () => {
const mockCredentials = [
{ id: 1, label: 'Test', zone_filter: 'example.com' },
{ id: 2, label: 'Test2', zone_filter: '*.test.com' },
- ]
- vi.mocked(credentialsApi.getCredentials).mockResolvedValue(mockCredentials as any)
+ ] as Awaited>
+ vi.mocked(credentialsApi.getCredentials).mockResolvedValue(mockCredentials)
const { result } = renderHook(() => useCredentials(1), { wrapper: createWrapper() })
@@ -64,8 +64,8 @@ describe('useCredentials', () => {
describe('useCredential', () => {
it('fetches a single credential', async () => {
- const mockCredential = { id: 1, label: 'Test', zone_filter: 'example.com' }
- vi.mocked(credentialsApi.getCredential).mockResolvedValue(mockCredential as any)
+ const mockCredential = { id: 1, label: 'Test', zone_filter: 'example.com' } as Awaited>
+ vi.mocked(credentialsApi.getCredential).mockResolvedValue(mockCredential)
const { result } = renderHook(() => useCredential(1, 1), { wrapper: createWrapper() })
@@ -85,8 +85,8 @@ describe('useCredentials', () => {
describe('useCreateCredential', () => {
it('creates a credential and invalidates queries', async () => {
- const mockCredential = { id: 3, label: 'New', zone_filter: 'new.com' }
- vi.mocked(credentialsApi.createCredential).mockResolvedValue(mockCredential as any)
+ const mockCredential = { id: 3, label: 'New', zone_filter: 'new.com' } as Awaited>
+ vi.mocked(credentialsApi.createCredential).mockResolvedValue(mockCredential)
const { result } = renderHook(() => useCreateCredential(), { wrapper: createWrapper() })
@@ -122,8 +122,8 @@ describe('useCredentials', () => {
describe('useUpdateCredential', () => {
it('updates a credential and invalidates queries', async () => {
- const mockCredential = { id: 1, label: 'Updated', zone_filter: 'updated.com' }
- vi.mocked(credentialsApi.updateCredential).mockResolvedValue(mockCredential as any)
+ const mockCredential = { id: 1, label: 'Updated', zone_filter: 'updated.com' } as Awaited>
+ vi.mocked(credentialsApi.updateCredential).mockResolvedValue(mockCredential)
const { result } = renderHook(() => useUpdateCredential(), { wrapper: createWrapper() })
diff --git a/frontend/src/hooks/useDocker.ts b/frontend/src/hooks/useDocker.ts
index 7a8017fc..24e11e0e 100644
--- a/frontend/src/hooks/useDocker.ts
+++ b/frontend/src/hooks/useDocker.ts
@@ -12,10 +12,11 @@ export function useDocker(host?: string | null, serverId?: string | null) {
queryFn: async () => {
try {
return await dockerApi.listContainers(host || undefined, serverId || undefined)
- } catch (err: any) {
+ } catch (err: unknown) {
// Extract helpful error message from response
- if (err.response?.status === 503) {
- const details = err.response?.data?.details
+ const error = err as { response?: { status?: number; data?: { details?: string } } }
+ if (error.response?.status === 503) {
+ const details = error.response?.data?.details
const message = details || 'Docker service unavailable. Check that Docker is running.'
throw new Error(message)
}
diff --git a/frontend/src/hooks/useJSONImport.ts b/frontend/src/hooks/useJSONImport.ts
new file mode 100644
index 00000000..96287b9b
--- /dev/null
+++ b/frontend/src/hooks/useJSONImport.ts
@@ -0,0 +1,84 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ uploadJSONExport,
+ commitJSONImport,
+ cancelJSONImport,
+ JSONImportPreview,
+ JSONImportCommitResult,
+} from '../api/jsonImport';
+
+/**
+ * Hook for managing JSON import workflow.
+ * Provides upload, commit, and cancel functionality with state management.
+ */
+export function useJSONImport() {
+ const queryClient = useQueryClient();
+ const [preview, setPreview] = useState(null);
+ const [sessionId, setSessionId] = useState(null);
+ const [commitResult, setCommitResult] = useState(null);
+
+ const uploadMutation = useMutation({
+ mutationFn: uploadJSONExport,
+ onSuccess: (data) => {
+ setPreview(data);
+ setSessionId(data.session.id);
+ },
+ });
+
+ const commitMutation = useMutation({
+ mutationFn: ({
+ resolutions,
+ names,
+ }: {
+ resolutions: Record;
+ names: Record;
+ }) => {
+ if (!sessionId) throw new Error('No active session');
+ return commitJSONImport(sessionId, resolutions, names);
+ },
+ onSuccess: (data) => {
+ setCommitResult(data);
+ setPreview(null);
+ setSessionId(null);
+ queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
+ },
+ });
+
+ const cancelMutation = useMutation({
+ mutationFn: cancelJSONImport,
+ onSuccess: () => {
+ setPreview(null);
+ setSessionId(null);
+ },
+ });
+
+ const clearCommitResult = () => {
+ setCommitResult(null);
+ };
+
+ const reset = () => {
+ setPreview(null);
+ setSessionId(null);
+ setCommitResult(null);
+ };
+
+ return {
+ preview,
+ sessionId,
+ loading: uploadMutation.isPending,
+ error: uploadMutation.error,
+ upload: uploadMutation.mutateAsync,
+ commit: (resolutions: Record, names: Record) =>
+ commitMutation.mutateAsync({ resolutions, names }),
+ committing: commitMutation.isPending,
+ commitError: commitMutation.error,
+ commitResult,
+ clearCommitResult,
+ cancel: cancelMutation.mutateAsync,
+ cancelling: cancelMutation.isPending,
+ reset,
+ };
+}
+
+export type { JSONImportPreview, JSONImportCommitResult };
diff --git a/frontend/src/hooks/useNPMImport.ts b/frontend/src/hooks/useNPMImport.ts
new file mode 100644
index 00000000..dc9211a8
--- /dev/null
+++ b/frontend/src/hooks/useNPMImport.ts
@@ -0,0 +1,84 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ uploadNPMExport,
+ commitNPMImport,
+ cancelNPMImport,
+ NPMImportPreview,
+ NPMImportCommitResult,
+} from '../api/npmImport';
+
+/**
+ * Hook for managing NPM import workflow.
+ * Provides upload, commit, and cancel functionality with state management.
+ */
+export function useNPMImport() {
+ const queryClient = useQueryClient();
+ const [preview, setPreview] = useState(null);
+ const [sessionId, setSessionId] = useState(null);
+ const [commitResult, setCommitResult] = useState(null);
+
+ const uploadMutation = useMutation({
+ mutationFn: uploadNPMExport,
+ onSuccess: (data) => {
+ setPreview(data);
+ setSessionId(data.session.id);
+ },
+ });
+
+ const commitMutation = useMutation({
+ mutationFn: ({
+ resolutions,
+ names,
+ }: {
+ resolutions: Record;
+ names: Record;
+ }) => {
+ if (!sessionId) throw new Error('No active session');
+ return commitNPMImport(sessionId, resolutions, names);
+ },
+ onSuccess: (data) => {
+ setCommitResult(data);
+ setPreview(null);
+ setSessionId(null);
+ queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
+ },
+ });
+
+ const cancelMutation = useMutation({
+ mutationFn: cancelNPMImport,
+ onSuccess: () => {
+ setPreview(null);
+ setSessionId(null);
+ },
+ });
+
+ const clearCommitResult = () => {
+ setCommitResult(null);
+ };
+
+ const reset = () => {
+ setPreview(null);
+ setSessionId(null);
+ setCommitResult(null);
+ };
+
+ return {
+ preview,
+ sessionId,
+ loading: uploadMutation.isPending,
+ error: uploadMutation.error,
+ upload: uploadMutation.mutateAsync,
+ commit: (resolutions: Record, names: Record) =>
+ commitMutation.mutateAsync({ resolutions, names }),
+ committing: commitMutation.isPending,
+ commitError: commitMutation.error,
+ commitResult,
+ clearCommitResult,
+ cancel: cancelMutation.mutateAsync,
+ cancelling: cancelMutation.isPending,
+ reset,
+ };
+}
+
+export type { NPMImportPreview, NPMImportCommitResult };
diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json
index 1b4de0cd..eed8e491 100644
--- a/frontend/src/locales/de/translation.json
+++ b/frontend/src/locales/de/translation.json
@@ -541,7 +541,11 @@
"deleteUser": "Benutzer löschen",
"inviteUrlPreview": "Einladungslink-Vorschau",
"inviteUrlWarning": "Anwendungs-URL ist nicht konfiguriert. Dieser Link funktioniert möglicherweise nicht für externe Benutzer.",
- "configureApplicationUrl": "Anwendungs-URL konfigurieren"
+ "configureApplicationUrl": "Anwendungs-URL konfigurieren",
+ "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"
},
"dashboard": {
"title": "Dashboard",
diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json
index 95e308ba..1a118724 100644
--- a/frontend/src/locales/en/translation.json
+++ b/frontend/src/locales/en/translation.json
@@ -69,6 +69,8 @@
"accountManagement": "Account Management",
"import": "Import",
"caddyfile": "Caddyfile",
+ "importNPM": "Import NPM",
+ "importJSON": "Import JSON",
"backups": "Backups",
"logs": "Logs",
"securityHeaders": "Security Headers",
@@ -462,7 +464,18 @@
"failedToDeleteMonitor": "Failed to delete monitor",
"failedToUpdateMonitor": "Failed to update monitor",
"failedToTriggerCheck": "Failed to trigger health check",
- "noHistoryAvailable": "No history available"
+ "noHistoryAvailable": "No history available",
+ "addMonitor": "Add Monitor",
+ "syncWithHosts": "Sync with Hosts",
+ "createMonitor": "Create Monitor",
+ "monitorCreated": "Monitor created successfully",
+ "syncComplete": "Sync complete",
+ "syncing": "Syncing...",
+ "monitorType": "Type",
+ "monitorUrl": "URL",
+ "monitorTypeHttp": "HTTP",
+ "monitorTypeTcp": "TCP",
+ "urlPlaceholder": "https://example.com or tcp://host:port"
},
"domains": {
"title": "Domains",
@@ -589,7 +602,11 @@
"deleteUser": "Delete User",
"inviteUrlPreview": "Invite Link Preview",
"inviteUrlWarning": "Application URL is not configured. This link may not work for external users.",
- "configureApplicationUrl": "Configure Application URL"
+ "configureApplicationUrl": "Configure Application URL",
+ "resendInvite": "Resend Invite",
+ "inviteResent": "Invitation resent successfully",
+ "inviteCreatedNoEmail": "New invite created. Email could not be sent.",
+ "resendFailed": "Failed to resend invitation"
},
"dashboard": {
"title": "Dashboard",
@@ -750,6 +767,38 @@
"creatingBackup": "Creating backup...",
"importing": "Importing CrowdSec..."
},
+ "importNPM": {
+ "title": "Import from NPM",
+ "description": "Import proxy hosts from Nginx Proxy Manager export",
+ "enterContent": "Please paste NPM export JSON",
+ "invalidJSON": "Invalid JSON format",
+ "upload": "Upload & Preview",
+ "import": "Import",
+ "success": "Import completed successfully",
+ "previewTitle": "Preview Import",
+ "conflict": "Conflict",
+ "new": "New",
+ "skip": "Skip",
+ "keep": "Keep Existing",
+ "replace": "Replace",
+ "cancelConfirm": "Are you sure you want to cancel this import?"
+ },
+ "importJSON": {
+ "title": "Import from JSON",
+ "description": "Import configuration from JSON export",
+ "enterContent": "Please paste JSON configuration",
+ "invalidJSON": "Invalid JSON format",
+ "upload": "Upload & Preview",
+ "import": "Import",
+ "success": "Import completed successfully",
+ "previewTitle": "Preview Import",
+ "conflict": "Conflict",
+ "new": "New",
+ "skip": "Skip",
+ "keep": "Keep Existing",
+ "replace": "Replace",
+ "cancelConfirm": "Are you sure you want to cancel this import?"
+ },
"systemSettings": {
"title": "System Settings",
"settingsSaved": "System settings saved",
diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json
index eb540dae..8d511641 100644
--- a/frontend/src/locales/es/translation.json
+++ b/frontend/src/locales/es/translation.json
@@ -541,7 +541,11 @@
"deleteUser": "Eliminar usuario",
"inviteUrlPreview": "Vista previa del enlace de invitación",
"inviteUrlWarning": "La URL de la aplicación no está configurada. Este enlace puede no funcionar para usuarios externos.",
- "configureApplicationUrl": "Configurar URL de aplicación"
+ "configureApplicationUrl": "Configurar URL de aplicación",
+ "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"
},
"dashboard": {
"title": "Panel de Control",
diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json
index e8b64cf2..d5c59806 100644
--- a/frontend/src/locales/fr/translation.json
+++ b/frontend/src/locales/fr/translation.json
@@ -541,7 +541,11 @@
"deleteUser": "Supprimer l'utilisateur",
"inviteUrlPreview": "Aperçu du lien d'invitation",
"inviteUrlWarning": "L'URL de l'application n'est pas configurée. Ce lien peut ne pas fonctionner pour les utilisateurs externes.",
- "configureApplicationUrl": "Configurer l'URL de l'application"
+ "configureApplicationUrl": "Configurer l'URL de l'application",
+ "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"
},
"dashboard": {
"title": "Tableau de bord",
diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json
index d56fc6aa..eab31f60 100644
--- a/frontend/src/locales/zh/translation.json
+++ b/frontend/src/locales/zh/translation.json
@@ -541,7 +541,11 @@
"deleteUser": "删除用户",
"inviteUrlPreview": "邀请链接预览",
"inviteUrlWarning": "未配置应用程序 URL。此链接可能无法为外部用户工作。",
- "configureApplicationUrl": "配置应用程序 URL"
+ "configureApplicationUrl": "配置应用程序 URL",
+ "resendInvite": "重新发送邀请",
+ "inviteResent": "邀请重新发送成功",
+ "inviteCreatedNoEmail": "新邀请已创建。无法发送电子邮件。",
+ "resendFailed": "重新发送邀请失败"
},
"dashboard": {
"title": "仪表板",
diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx
index c6a68f5a..fa621ee3 100644
--- a/frontend/src/pages/Account.tsx
+++ b/frontend/src/pages/Account.tsx
@@ -37,6 +37,7 @@ export default function Account() {
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()
@@ -68,9 +69,9 @@ export default function Account() {
}
}, [email])
- // Initialize cert email state
+ // Initialize cert email state only once, when both settings and profile are loaded
useEffect(() => {
- if (settings && profile) {
+ if (!certEmailInitialized && settings && profile) {
const savedEmail = settings['caddy.email']
if (savedEmail && savedEmail !== profile.email) {
setCertEmail(savedEmail)
@@ -79,8 +80,9 @@ export default function Account() {
setCertEmail(profile.email)
setUseUserEmail(true)
}
+ setCertEmailInitialized(true)
}
- }, [settings, profile])
+ }, [settings, profile, certEmailInitialized])
// Validate cert email
useEffect(() => {
@@ -215,6 +217,9 @@ export default function Account() {
})
}
+ // 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) {
@@ -349,12 +354,21 @@ export default function Account() {
onChange={(e) => setCertEmail(e.target.value)}
required={!useUserEmail}
error={certEmailValid === false ? t('errors.invalidEmail') : undefined}
+ errorTestId="cert-email-error"
+ aria-invalid={certEmailValid === false}
/>
)}