chore: remove cached
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import { ToastContainer } from './components/Toast'
|
||||
import { SetupGuard } from './components/SetupGuard'
|
||||
import { LoadingOverlay } from './components/LoadingStates'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const ProxyHosts = lazy(() => import('./pages/ProxyHosts'))
|
||||
const RemoteServers = lazy(() => import('./pages/RemoteServers'))
|
||||
const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
|
||||
const Certificates = lazy(() => import('./pages/Certificates'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
const Account = lazy(() => import('./pages/Account'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Backups = lazy(() => import('./pages/Backups'))
|
||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||
const Logs = lazy(() => import('./pages/Logs'))
|
||||
const Domains = lazy(() => import('./pages/Domains'))
|
||||
const Uptime = lazy(() => import('./pages/Uptime'))
|
||||
const Notifications = lazy(() => import('./pages/Notifications'))
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Setup = lazy(() => import('./pages/Setup'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingOverlay message="Loading application..." />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
</RequireAuth>
|
||||
</SetupGuard>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="proxy-hosts" element={<ProxyHosts />} />
|
||||
<Route path="remote-servers" element={<RemoteServers />} />
|
||||
<Route path="domains" element={<Domains />} />
|
||||
<Route path="certificates" element={<Certificates />} />
|
||||
<Route path="uptime" element={<Uptime />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="import" element={<ImportCaddy />} />
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
</Route>
|
||||
|
||||
{/* Tasks Routes */}
|
||||
<Route path="tasks" element={<Tasks />}>
|
||||
<Route index element={<Backups />} />
|
||||
<Route path="backups" element={<Backups />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
</Route>
|
||||
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<ToastContainer />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import client from '../client';
|
||||
import { getCertificates, uploadCertificate, deleteCertificate, Certificate } from '../certificates';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('certificates API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockCert: Certificate = {
|
||||
id: 1,
|
||||
domain: 'example.com',
|
||||
issuer: 'Let\'s Encrypt',
|
||||
expires_at: '2023-01-01',
|
||||
status: 'valid',
|
||||
provider: 'letsencrypt',
|
||||
};
|
||||
|
||||
it('getCertificates calls client.get', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: [mockCert] });
|
||||
const result = await getCertificates();
|
||||
expect(client.get).toHaveBeenCalledWith('/certificates');
|
||||
expect(result).toEqual([mockCert]);
|
||||
});
|
||||
|
||||
it('uploadCertificate calls client.post with FormData', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockCert });
|
||||
const certFile = new File(['cert'], 'cert.pem', { type: 'text/plain' });
|
||||
const keyFile = new File(['key'], 'key.pem', { type: 'text/plain' });
|
||||
|
||||
const result = await uploadCertificate('My Cert', certFile, keyFile);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/certificates', expect.any(FormData), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
expect(result).toEqual(mockCert);
|
||||
});
|
||||
|
||||
it('deleteCertificate calls client.delete', async () => {
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: {} });
|
||||
await deleteCertificate(1);
|
||||
expect(client.delete).toHaveBeenCalledWith('/certificates/1');
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import client from '../client';
|
||||
import { getDomains, createDomain, deleteDomain, Domain } from '../domains';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('domains API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockDomain: Domain = {
|
||||
id: 1,
|
||||
uuid: '123',
|
||||
name: 'example.com',
|
||||
created_at: '2023-01-01',
|
||||
};
|
||||
|
||||
it('getDomains calls client.get', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: [mockDomain] });
|
||||
const result = await getDomains();
|
||||
expect(client.get).toHaveBeenCalledWith('/domains');
|
||||
expect(result).toEqual([mockDomain]);
|
||||
});
|
||||
|
||||
it('createDomain calls client.post', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockDomain });
|
||||
const result = await createDomain('example.com');
|
||||
expect(client.post).toHaveBeenCalledWith('/domains', { name: 'example.com' });
|
||||
expect(result).toEqual(mockDomain);
|
||||
});
|
||||
|
||||
it('deleteDomain calls client.delete', async () => {
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: {} });
|
||||
await deleteDomain('123');
|
||||
expect(client.delete).toHaveBeenCalledWith('/domains/123');
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import client from '../client';
|
||||
import {
|
||||
getProxyHosts,
|
||||
getProxyHost,
|
||||
createProxyHost,
|
||||
updateProxyHost,
|
||||
deleteProxyHost,
|
||||
testProxyHostConnection,
|
||||
ProxyHost
|
||||
} from '../proxyHosts';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('proxyHosts API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockHost: ProxyHost = {
|
||||
uuid: '123',
|
||||
domain_names: 'example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2023-01-01',
|
||||
updated_at: '2023-01-01',
|
||||
};
|
||||
|
||||
it('getProxyHosts calls client.get', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: [mockHost] });
|
||||
const result = await getProxyHosts();
|
||||
expect(client.get).toHaveBeenCalledWith('/proxy-hosts');
|
||||
expect(result).toEqual([mockHost]);
|
||||
});
|
||||
|
||||
it('getProxyHost calls client.get with uuid', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockHost });
|
||||
const result = await getProxyHost('123');
|
||||
expect(client.get).toHaveBeenCalledWith('/proxy-hosts/123');
|
||||
expect(result).toEqual(mockHost);
|
||||
});
|
||||
|
||||
it('createProxyHost calls client.post', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockHost });
|
||||
const newHost = { domain_names: 'example.com' };
|
||||
const result = await createProxyHost(newHost);
|
||||
expect(client.post).toHaveBeenCalledWith('/proxy-hosts', newHost);
|
||||
expect(result).toEqual(mockHost);
|
||||
});
|
||||
|
||||
it('updateProxyHost calls client.put', async () => {
|
||||
vi.mocked(client.put).mockResolvedValue({ data: mockHost });
|
||||
const updates = { enabled: false };
|
||||
const result = await updateProxyHost('123', updates);
|
||||
expect(client.put).toHaveBeenCalledWith('/proxy-hosts/123', updates);
|
||||
expect(result).toEqual(mockHost);
|
||||
});
|
||||
|
||||
it('deleteProxyHost calls client.delete', async () => {
|
||||
vi.mocked(client.delete).mockResolvedValue({ data: {} });
|
||||
await deleteProxyHost('123');
|
||||
expect(client.delete).toHaveBeenCalledWith('/proxy-hosts/123');
|
||||
});
|
||||
|
||||
it('testProxyHostConnection calls client.post', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ data: {} });
|
||||
await testProxyHostConnection('localhost', 8080);
|
||||
expect(client.post).toHaveBeenCalledWith('/proxy-hosts/test', {
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import client from '../client'
|
||||
import { checkUpdates, getNotifications, markNotificationRead, markAllNotificationsRead } from '../system'
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('System API', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('checkUpdates calls /system/updates', async () => {
|
||||
const mockData = { available: true, latest_version: '1.0.0', changelog_url: 'url' }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await checkUpdates()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/system/updates')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('getNotifications calls /notifications', async () => {
|
||||
const mockData = [{ id: '1', title: 'Test' }]
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await getNotifications()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/notifications', { params: { unread: false } })
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('getNotifications calls /notifications with unreadOnly=true', async () => {
|
||||
const mockData = [{ id: '1', title: 'Test' }]
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await getNotifications(true)
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/notifications', { params: { unread: true } })
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('markNotificationRead calls /notifications/:id/read', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({})
|
||||
|
||||
await markNotificationRead('123')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/123/read')
|
||||
})
|
||||
|
||||
it('markAllNotificationsRead calls /notifications/read-all', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({})
|
||||
|
||||
await markAllNotificationsRead()
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/read-all')
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export const getBackups = async (): Promise<BackupFile[]> => {
|
||||
const response = await client.get<BackupFile[]>('/backups');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createBackup = async (): Promise<{ filename: string }> => {
|
||||
const response = await client.post<{ filename: string }>('/backups');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const restoreBackup = async (filename: string): Promise<void> => {
|
||||
await client.post(`/backups/${filename}/restore`);
|
||||
};
|
||||
|
||||
export const deleteBackup = async (filename: string): Promise<void> => {
|
||||
await client.delete(`/backups/${filename}`);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
export interface Certificate {
|
||||
id?: number
|
||||
name?: string
|
||||
domain: string
|
||||
issuer: string
|
||||
expires_at: string
|
||||
status: 'valid' | 'expiring' | 'expired'
|
||||
provider: string
|
||||
}
|
||||
|
||||
export async function getCertificates(): Promise<Certificate[]> {
|
||||
const response = await client.get<Certificate[]>('/certificates')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise<Certificate> {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('certificate_file', certFile)
|
||||
formData.append('key_file', keyFile)
|
||||
|
||||
const response = await client.post<Certificate>('/certificates', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteCertificate(id: number): Promise<void> {
|
||||
await client.delete(`/certificates/${id}`)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1'
|
||||
});
|
||||
|
||||
export default client;
|
||||
@@ -1,26 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
export interface DockerPort {
|
||||
private_port: number
|
||||
public_port: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface DockerContainer {
|
||||
id: string
|
||||
names: string[]
|
||||
image: string
|
||||
state: string
|
||||
status: string
|
||||
network: string
|
||||
ip: string
|
||||
ports: DockerPort[]
|
||||
}
|
||||
|
||||
export const dockerApi = {
|
||||
listContainers: async (host?: string): Promise<DockerContainer[]> => {
|
||||
const params = host ? { host } : undefined
|
||||
const response = await client.get<DockerContainer[]>('/docker/containers', { params })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
export interface Domain {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const getDomains = async (): Promise<Domain[]> => {
|
||||
const { data } = await client.get<Domain[]>('/domains')
|
||||
return data
|
||||
}
|
||||
|
||||
export const createDomain = async (name: string): Promise<Domain> => {
|
||||
const { data } = await client.post<Domain>('/domains', { name })
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteDomain = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/domains/${uuid}`)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
version: string;
|
||||
git_commit: string;
|
||||
build_time: string;
|
||||
}
|
||||
|
||||
export const checkHealth = async (): Promise<HealthResponse> => {
|
||||
const { data } = await client.get<HealthResponse>('/health');
|
||||
return data;
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface ImportSession {
|
||||
id: string;
|
||||
state: 'pending' | 'reviewing' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
session: ImportSession;
|
||||
preview: {
|
||||
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
|
||||
conflicts: string[];
|
||||
errors: string[];
|
||||
};
|
||||
caddyfile_content?: string;
|
||||
}
|
||||
|
||||
export const uploadCaddyfile = async (content: string): Promise<ImportPreview> => {
|
||||
const { data } = await client.post<ImportPreview>('/import/upload', { content });
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getImportPreview = async (): Promise<ImportPreview> => {
|
||||
const { data } = await client.get<ImportPreview>('/import/preview');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const commitImport = async (sessionUUID: string, resolutions: Record<string, string>): Promise<void> => {
|
||||
await client.post('/import/commit', { session_uuid: sessionUUID, resolutions });
|
||||
};
|
||||
|
||||
export const cancelImport = async (): Promise<void> => {
|
||||
await client.post('/import/cancel');
|
||||
};
|
||||
|
||||
export const getImportStatus = async (): Promise<{ has_pending: boolean; session?: ImportSession }> => {
|
||||
// Note: Assuming there might be a status endpoint or we infer from preview.
|
||||
// If no dedicated status endpoint exists in backend, we might rely on preview returning 404 or empty.
|
||||
// Based on previous context, there wasn't an explicit status endpoint mentioned in the simple API,
|
||||
// but the hook used `importAPI.status()`. I'll check the backend routes if needed.
|
||||
// For now, I'll implement it assuming /import/preview can serve as status check or there is a /import/status.
|
||||
// Let's check the backend routes to be sure.
|
||||
try {
|
||||
const { data } = await client.get<{ has_pending: boolean; session?: ImportSession }>('/import/status');
|
||||
return data;
|
||||
} catch {
|
||||
// Fallback if status endpoint doesn't exist, though the hook used it.
|
||||
return { has_pending: false };
|
||||
}
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface LogFile {
|
||||
name: string;
|
||||
size: number;
|
||||
mod_time: string;
|
||||
}
|
||||
|
||||
export interface CaddyAccessLog {
|
||||
level: string;
|
||||
ts: number;
|
||||
logger: string;
|
||||
msg: string;
|
||||
request: {
|
||||
remote_ip: string;
|
||||
method: string;
|
||||
host: string;
|
||||
uri: string;
|
||||
proto: string;
|
||||
};
|
||||
status: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LogResponse {
|
||||
filename: string;
|
||||
logs: CaddyAccessLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface LogFilter {
|
||||
search?: string;
|
||||
host?: string;
|
||||
status?: string;
|
||||
level?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export const getLogs = async (): Promise<LogFile[]> => {
|
||||
const response = await client.get<LogFile[]>('/logs');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getLogContent = async (filename: string, filter: LogFilter = {}): Promise<LogResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.search) params.append('search', filter.search);
|
||||
if (filter.host) params.append('host', filter.host);
|
||||
if (filter.status) params.append('status', filter.status);
|
||||
if (filter.level) params.append('level', filter.level);
|
||||
if (filter.limit) params.append('limit', filter.limit.toString());
|
||||
if (filter.offset) params.append('offset', filter.offset.toString());
|
||||
if (filter.sort) params.append('sort', filter.sort);
|
||||
|
||||
const response = await client.get<LogResponse>(`/logs/${filename}?${params.toString()}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const downloadLog = (filename: string) => {
|
||||
// Direct window location change to trigger download
|
||||
// We need to use the base URL from the client config if possible,
|
||||
// but for now we assume relative path works with the proxy setup
|
||||
window.location.href = `/api/v1/logs/${filename}/download`;
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface NotificationProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
config?: string;
|
||||
enabled: boolean;
|
||||
notify_proxy_hosts: boolean;
|
||||
notify_remote_servers: boolean;
|
||||
notify_domains: boolean;
|
||||
notify_certs: boolean;
|
||||
notify_uptime: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getProviders = async () => {
|
||||
const response = await client.get<NotificationProvider[]>('/notifications/providers');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createProvider = async (data: Partial<NotificationProvider>) => {
|
||||
const response = await client.post<NotificationProvider>('/notifications/providers', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProvider = async (id: string, data: Partial<NotificationProvider>) => {
|
||||
const response = await client.put<NotificationProvider>(`/notifications/providers/${id}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProvider = async (id: string) => {
|
||||
await client.delete(`/notifications/providers/${id}`);
|
||||
};
|
||||
|
||||
export const testProvider = async (provider: Partial<NotificationProvider>) => {
|
||||
await client.post('/notifications/providers/test', provider);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface Location {
|
||||
uuid?: string;
|
||||
path: string;
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
}
|
||||
|
||||
export interface ProxyHost {
|
||||
uuid: string;
|
||||
domain_names: string;
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
http2_support: boolean;
|
||||
hsts_enabled: boolean;
|
||||
hsts_subdomains: boolean;
|
||||
block_exploits: boolean;
|
||||
websocket_support: boolean;
|
||||
locations: Location[];
|
||||
advanced_config?: string;
|
||||
enabled: boolean;
|
||||
certificate_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const getProxyHosts = async (): Promise<ProxyHost[]> => {
|
||||
const { data } = await client.get<ProxyHost[]>('/proxy-hosts');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getProxyHost = async (uuid: string): Promise<ProxyHost> => {
|
||||
const { data } = await client.get<ProxyHost>(`/proxy-hosts/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createProxyHost = async (host: Partial<ProxyHost>): Promise<ProxyHost> => {
|
||||
const { data } = await client.post<ProxyHost>('/proxy-hosts', host);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateProxyHost = async (uuid: string, host: Partial<ProxyHost>): Promise<ProxyHost> => {
|
||||
const { data } = await client.put<ProxyHost>(`/proxy-hosts/${uuid}`, host);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteProxyHost = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/proxy-hosts/${uuid}`);
|
||||
};
|
||||
|
||||
export const testProxyHostConnection = async (host: string, port: number): Promise<void> => {
|
||||
await client.post('/proxy-hosts/test', { forward_host: host, forward_port: port });
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface RemoteServer {
|
||||
uuid: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
enabled: boolean;
|
||||
reachable: boolean;
|
||||
last_check?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const getRemoteServers = async (enabledOnly = false): Promise<RemoteServer[]> => {
|
||||
const params = enabledOnly ? { enabled: true } : {};
|
||||
const { data } = await client.get<RemoteServer[]>('/remote-servers', { params });
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getRemoteServer = async (uuid: string): Promise<RemoteServer> => {
|
||||
const { data } = await client.get<RemoteServer>(`/remote-servers/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createRemoteServer = async (server: Partial<RemoteServer>): Promise<RemoteServer> => {
|
||||
const { data } = await client.post<RemoteServer>('/remote-servers', server);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateRemoteServer = async (uuid: string, server: Partial<RemoteServer>): Promise<RemoteServer> => {
|
||||
const { data } = await client.put<RemoteServer>(`/remote-servers/${uuid}`, server);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteRemoteServer = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/remote-servers/${uuid}`);
|
||||
};
|
||||
|
||||
export const testRemoteServerConnection = async (uuid: string): Promise<{ address: string }> => {
|
||||
const { data } = await client.post<{ address: string }>(`/remote-servers/${uuid}/test`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const testCustomRemoteServerConnection = async (host: string, port: number): Promise<{ address: string; reachable: boolean; error?: string }> => {
|
||||
const { data } = await client.post<{ address: string; reachable: boolean; error?: string }>('/remote-servers/test', { host, port });
|
||||
return data;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
export interface SettingsMap {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export const getSettings = async (): Promise<SettingsMap> => {
|
||||
const response = await client.get('/settings')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateSetting = async (key: string, value: string, category?: string, type?: string): Promise<void> => {
|
||||
await client.post('/settings', { key, value, category, type })
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface SetupStatus {
|
||||
setupRequired: boolean;
|
||||
}
|
||||
|
||||
export interface SetupRequest {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const getSetupStatus = async (): Promise<SetupStatus> => {
|
||||
const response = await client.get<SetupStatus>('/setup');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const performSetup = async (data: SetupRequest): Promise<void> => {
|
||||
await client.post('/setup', data);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface UpdateInfo {
|
||||
available: boolean;
|
||||
latest_version: string;
|
||||
changelog_url: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const checkUpdates = async (): Promise<UpdateInfo> => {
|
||||
const response = await client.get('/system/updates');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getNotifications = async (unreadOnly = false): Promise<Notification[]> => {
|
||||
const response = await client.get('/notifications', { params: { unread: unreadOnly } });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const markNotificationRead = async (id: string): Promise<void> => {
|
||||
await client.post(`/notifications/${id}/read`);
|
||||
};
|
||||
|
||||
export const markAllNotificationsRead = async (): Promise<void> => {
|
||||
await client.post('/notifications/read-all');
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface UptimeMonitor {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
last_check: string;
|
||||
latency: number;
|
||||
}
|
||||
|
||||
export interface UptimeHeartbeat {
|
||||
id: number;
|
||||
monitor_id: string;
|
||||
status: string;
|
||||
latency: number;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getMonitors = async () => {
|
||||
const response = await client.get<UptimeMonitor[]>('/uptime/monitors');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getMonitorHistory = async (id: string, limit: number = 50) => {
|
||||
const response = await client.get<UptimeHeartbeat[]>(`/uptime/monitors/${id}/history?limit=${limit}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const regenerateApiKey = async (): Promise<{ api_key: string }> => {
|
||||
const response = await client.post('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { deleteCertificate } from '../api/certificates'
|
||||
import { LoadingSpinner } from './LoadingStates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
export default function CertificateList() {
|
||||
const { certificates, isLoading, error } = useCertificates()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteCertificate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
toast.success('Certificate deleted')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to delete certificate: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (error) return <div className="text-red-500">Failed to load certificates</div>
|
||||
|
||||
return (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Name</th>
|
||||
<th className="px-6 py-3">Domain</th>
|
||||
<th className="px-6 py-3">Issuer</th>
|
||||
<th className="px-6 py-3">Expires</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{certificates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
No certificates found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
certificates.map((cert) => (
|
||||
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
|
||||
<td className="px-6 py-4">{cert.issuer}</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={cert.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{cert.provider === 'custom' && cert.id && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this certificate?')) {
|
||||
deleteMutation.mutate(cert.id!)
|
||||
}
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Delete Certificate"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles = {
|
||||
valid: 'bg-green-900/30 text-green-400 border-green-800',
|
||||
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
|
||||
expired: 'bg-red-900/30 text-red-400 border-red-800',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
valid: 'Valid',
|
||||
expiring: 'Expiring Soon',
|
||||
expired: 'Expired',
|
||||
}
|
||||
|
||||
const style = styles[status as keyof typeof styles] || styles.valid
|
||||
const label = labels[status as keyof typeof labels] || status
|
||||
|
||||
return (
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
interface Props {
|
||||
session: { id: string }
|
||||
onReview: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ImportBanner({ session, onReview, onCancel }: Props) {
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Pending Import Session</div>
|
||||
<div className="text-sm text-yellow-400/80">Session ID: {session.id}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReview}
|
||||
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-500 text-black rounded text-sm font-medium"
|
||||
>
|
||||
Review Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 text-yellow-300 border border-yellow-700 rounded text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface HostPreview {
|
||||
domain_names: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hosts: HostPreview[]
|
||||
conflicts: string[]
|
||||
errors: string[]
|
||||
caddyfileContent?: string
|
||||
onCommit: (resolutions: Record<string, string>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ImportReviewTable({ hosts, conflicts, errors, caddyfileContent, onCommit, onCancel }: Props) {
|
||||
const [resolutions, setResolutions] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {}
|
||||
conflicts.forEach((d: string) => { init[d] = 'keep' })
|
||||
return init
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showSource, setShowSource] = useState(false)
|
||||
|
||||
const handleCommit = async () => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCommit(resolutions)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to commit import')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{caddyfileContent && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-800 flex items-center justify-between cursor-pointer" onClick={() => setShowSource(!showSource)}>
|
||||
<h2 className="text-lg font-semibold text-white">Source Caddyfile Content</h2>
|
||||
<span className="text-gray-400 text-sm">{showSource ? 'Hide' : 'Show'}</span>
|
||||
</div>
|
||||
{showSource && (
|
||||
<div className="p-4 bg-gray-900 overflow-x-auto">
|
||||
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap">{caddyfileContent}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">Review Imported Hosts</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Committing...' : 'Commit Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="m-4 bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors?.length > 0 && (
|
||||
<div className="m-4 bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded">
|
||||
<div className="font-medium mb-2">Issues found during parsing</div>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Domain Names
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Conflict Resolution
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{hosts.map((h, idx) => {
|
||||
const domain = h.domain_names
|
||||
const hasConflict = conflicts.includes(domain)
|
||||
return (
|
||||
<tr key={`${domain}-${idx}`} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">{domain}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{hasConflict ? (
|
||||
<select
|
||||
value={resolutions[domain]}
|
||||
onChange={e => setResolutions({ ...resolutions, [domain]: e.target.value })}
|
||||
className="bg-gray-900 border border-gray-700 text-white rounded px-2 py-1"
|
||||
>
|
||||
<option value="keep">Keep Existing</option>
|
||||
<option value="overwrite">Overwrite</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
|
||||
No conflict
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import { Button } from './ui/Button'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { checkHealth } from '../api/health'
|
||||
import NotificationCenter from './NotificationCenter'
|
||||
import SystemStatus from './SystemStatus'
|
||||
import { Menu, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebarCollapsed')
|
||||
return saved ? JSON.parse(saved) : false
|
||||
})
|
||||
const [expandedMenus, setExpandedMenus] = useState<string[]>([])
|
||||
const { logout, user } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
||||
const toggleMenu = (name: string) => {
|
||||
setExpandedMenus(prev =>
|
||||
prev.includes(name)
|
||||
? prev.filter(item => item !== name)
|
||||
: [...prev, name]
|
||||
)
|
||||
}
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: checkHealth,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
})
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', path: '/', icon: '📊' },
|
||||
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
|
||||
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
|
||||
{ name: 'Domains', path: '/domains', icon: '🌍' },
|
||||
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
|
||||
{ name: 'Uptime', path: '/uptime', icon: '📈' },
|
||||
{ name: 'Notifications', path: '/notifications', icon: '🔔' },
|
||||
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
|
||||
{
|
||||
name: 'Settings',
|
||||
path: '/settings',
|
||||
icon: '⚙️',
|
||||
children: [
|
||||
{ name: 'System', path: '/settings/system', icon: '⚙️' },
|
||||
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
path: '/tasks',
|
||||
icon: '📋',
|
||||
children: [
|
||||
{ name: 'Backups', path: '/tasks/backups', icon: '💾' },
|
||||
{ name: 'Logs', path: '/tasks/logs', icon: '📝' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-bg dark:bg-dark-bg flex transition-colors duration-200">
|
||||
{/* Mobile Header */}
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 z-40">
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">CPM+</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationCenter />
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="sm" onClick={() => setMobileSidebarOpen(!mobileSidebarOpen)}>
|
||||
{mobileSidebarOpen ? '✕' : '☰'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`
|
||||
fixed lg:fixed inset-y-0 left-0 z-30 transform transition-all duration-200 ease-in-out
|
||||
bg-white dark:bg-dark-sidebar border-r border-gray-200 dark:border-gray-800 flex flex-col
|
||||
${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
${isCollapsed ? 'w-20' : 'w-64'}
|
||||
`}>
|
||||
<div className={`p-4 hidden lg:flex items-center justify-center`}>
|
||||
{/* Logo moved to header */}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 px-4 mt-16 lg:mt-0">
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
if (item.children) {
|
||||
// Collapsible Group
|
||||
const isExpanded = expandedMenus.includes(item.name)
|
||||
const isActive = location.pathname.startsWith(item.path!)
|
||||
|
||||
// If sidebar is collapsed, render as a simple link (icon only)
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path!}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors justify-center ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
title={item.name}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// If sidebar is expanded, render as collapsible accordion
|
||||
return (
|
||||
<div key={item.name} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleMenu(item.name)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-blue-700 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pl-11 space-y-1">
|
||||
{item.children.map((child) => {
|
||||
const isChildActive = location.pathname === child.path
|
||||
return (
|
||||
<Link
|
||||
key={child.path}
|
||||
to={child.path!}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className={`block py-2 px-3 rounded-md text-sm transition-colors ${
|
||||
isChildActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = location.pathname === item.path
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
|
||||
} ${isCollapsed ? 'justify-center' : ''}`}
|
||||
title={isCollapsed ? item.name : ''}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
{!isCollapsed && item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={`mt-2 border-t border-gray-200 dark:border-gray-800 pt-4 ${isCollapsed ? 'hidden' : ''}`}>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 text-center mb-2 flex flex-col gap-0.5">
|
||||
<span>Version {health?.version || 'dev'}</span>
|
||||
{health?.git_commit && health.git_commit !== 'unknown' && (
|
||||
<span className="text-[10px] opacity-75 font-mono">
|
||||
({health.git_commit.substring(0, 7)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileSidebarOpen(false)
|
||||
logout()
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-medium transition-colors text-red-600 dark:text-red-400 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900"
|
||||
>
|
||||
<span className="text-lg">🚪</span>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsed Logout */}
|
||||
{isCollapsed && (
|
||||
<div className="mt-2 border-t border-gray-200 dark:border-gray-800 pt-4 pb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileSidebarOpen(false)
|
||||
logout()
|
||||
}}
|
||||
className="w-full flex items-center justify-center p-3 rounded-lg transition-colors text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
title="Logout"
|
||||
>
|
||||
<span className="text-lg">🚪</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{/* Mobile Overlay */}
|
||||
{mobileSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-900/50 z-20 lg:hidden"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={`flex-1 min-w-0 overflow-auto pt-16 lg:pt-0 flex flex-col transition-all duration-200 ${isCollapsed ? 'lg:ml-20' : 'lg:ml-64'}`}>
|
||||
{/* Desktop Header */}
|
||||
<header className="hidden lg:flex items-center justify-between px-8 py-4 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 relative">
|
||||
<div className="w-1/3 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">CPM+</h1>
|
||||
</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">
|
||||
{user.name}
|
||||
</Link>
|
||||
)}
|
||||
<SystemStatus />
|
||||
<NotificationCenter />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 lg:p-8 max-w-7xl mx-auto w-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-8 h-8 border-3',
|
||||
lg: 'w-12 h-12 border-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin`}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-slate-800 rounded-lg p-6 flex flex-col items-center gap-4 shadow-xl">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="text-slate-300">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingCard() {
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-lg p-6 animate-pulse">
|
||||
<div className="h-6 bg-slate-700 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-slate-700 rounded w-full"></div>
|
||||
<div className="h-4 bg-slate-700 rounded w-5/6"></div>
|
||||
<div className="h-4 bg-slate-700 rounded w-4/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = '📦',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div className="text-6xl mb-4">{icon}</div>
|
||||
<h3 className="text-xl font-semibold text-slate-200 mb-2">{title}</h3>
|
||||
<p className="text-slate-400 mb-6 max-w-md">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Search, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
interface LogFiltersProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
status: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
level: string;
|
||||
onLevelChange: (value: string) => void;
|
||||
host: string;
|
||||
onHostChange: (value: string) => void;
|
||||
sort: 'asc' | 'desc';
|
||||
onSortChange: (value: 'asc' | 'desc') => void;
|
||||
onRefresh: () => void;
|
||||
onDownload: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
status,
|
||||
onStatusChange,
|
||||
level,
|
||||
onLevelChange,
|
||||
host,
|
||||
onHostChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
onRefresh,
|
||||
onDownload,
|
||||
isLoading
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="block w-full pl-10 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-48">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by Host"
|
||||
value={host}
|
||||
onChange={(e) => onHostChange(e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-32">
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => onLevelChange(e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="WARN">Warn</option>
|
||||
<option value="ERROR">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-32">
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="2xx">2xx Success</option>
|
||||
<option value="3xx">3xx Redirect</option>
|
||||
<option value="4xx">4xx Client Error</option>
|
||||
<option value="5xx">5xx Server Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-32">
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => onSortChange(e.target.value as 'asc' | 'desc')}
|
||||
className="block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onRefresh} variant="secondary" size="sm" isLoading={isLoading}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={onDownload} variant="secondary" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import { CaddyAccessLog } from '../api/logs';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface LogTableProps {
|
||||
logs: CaddyAccessLog[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const LogTable: React.FC<LogTableProps> = ({ logs, isLoading }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-64 flex items-center justify-center text-gray-500">
|
||||
Loading logs...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-64 flex items-center justify-center text-gray-500">
|
||||
No logs found matching criteria.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Time</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Method</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Host</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Path</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">IP</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Latency</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{logs.map((log, idx) => {
|
||||
// Check if this is a structured access log or a plain text system log
|
||||
const isAccessLog = log.status > 0 || (log.request && log.request.method);
|
||||
|
||||
if (!isAccessLog) {
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')}
|
||||
</td>
|
||||
<td colSpan={7} className="px-6 py-4 text-sm text-gray-900 dark:text-white font-mono whitespace-pre-wrap break-all">
|
||||
{log.msg}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{log.status > 0 && (
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${log.status >= 500 ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' :
|
||||
log.status >= 400 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||
log.status >= 300 ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{log.request?.method}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.request?.host}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate" title={log.request?.uri}>
|
||||
{log.request?.uri}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.request?.remote_ip}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.duration > 0 ? (log.duration * 1000).toFixed(2) + 'ms' : ''}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate" title={log.msg}>
|
||||
{log.msg}
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, X, Info, AlertTriangle, AlertCircle, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
import { getNotifications, markNotificationRead, markAllNotificationsRead, checkUpdates } from '../api/system';
|
||||
|
||||
const NotificationCenter: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: notifications = [] } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: () => getNotifications(true),
|
||||
refetchInterval: 30000, // Poll every 30s
|
||||
});
|
||||
|
||||
const { data: updateInfo } = useQuery({
|
||||
queryKey: ['system-updates'],
|
||||
queryFn: checkUpdates,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const unreadCount = notifications.length + (updateInfo?.available ? 1 : 0);
|
||||
const hasCritical = notifications.some(n => n.type === 'error');
|
||||
const hasWarning = notifications.some(n => n.type === 'warning') || updateInfo?.available;
|
||||
|
||||
const getBellColor = () => {
|
||||
if (hasCritical) return 'text-red-500 hover:text-red-600';
|
||||
if (hasWarning) return 'text-yellow-500 hover:text-yellow-600';
|
||||
return 'text-green-500 hover:text-green-600';
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'warning': return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
|
||||
case 'error': return <AlertCircle className="w-5 h-5 text-red-500" />;
|
||||
default: return <Info className="w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`relative p-2 focus:outline-none transition-colors ${getBellColor()}`}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="w-6 h-6" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
data-testid="notification-backdrop"
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
></div>
|
||||
<div className="absolute right-0 z-20 w-80 mt-2 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Notifications</h3>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
className="text-xs text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{/* Update Notification */}
|
||||
{updateInfo?.available && (
|
||||
<div className="flex items-start px-4 py-3 border-b dark:border-gray-700 bg-yellow-50 dark:bg-yellow-900/10 hover:bg-yellow-100 dark:hover:bg-yellow-900/20">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Update Available: {updateInfo.latest_version}
|
||||
</p>
|
||||
<a
|
||||
href={updateInfo.changelog_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 flex items-center"
|
||||
>
|
||||
View Changelog <ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifications.length === 0 && !updateInfo?.available ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No new notifications
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-start px-4 py-3 border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
onClick={() => markReadMutation.mutate(notification.id)}
|
||||
className="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import { calculatePasswordStrength } from '../utils/passwordStrength';
|
||||
|
||||
interface Props {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter: React.FC<Props> = ({ password }) => {
|
||||
const { score, label, color, feedback } = calculatePasswordStrength(password);
|
||||
|
||||
// Calculate width percentage based on score (0-4)
|
||||
// 0: 5%, 1: 25%, 2: 50%, 3: 75%, 4: 100%
|
||||
const width = Math.max(5, (score / 4) * 100);
|
||||
|
||||
// Map color name to Tailwind classes
|
||||
const getColorClass = (c: string) => {
|
||||
switch (c) {
|
||||
case 'red': return 'bg-red-500';
|
||||
case 'yellow': return 'bg-yellow-500';
|
||||
case 'green': return 'bg-green-500';
|
||||
default: return 'bg-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColorClass = (c: string) => {
|
||||
switch (c) {
|
||||
case 'red': return 'text-red-500';
|
||||
case 'yellow': return 'text-yellow-600';
|
||||
case 'green': return 'text-green-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className={`font-medium ${getTextColorClass(color)}`}>
|
||||
{label}
|
||||
</span>
|
||||
{feedback.length > 0 && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{feedback[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-out ${getColorClass(color)}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,576 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CircleHelp, AlertCircle, Check, X, Loader2 } from 'lucide-react'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { testProxyHostConnection } from '../api/proxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useDocker } from '../hooks/useDocker'
|
||||
import { parse } from 'tldts'
|
||||
|
||||
interface ProxyHostFormProps {
|
||||
host?: ProxyHost
|
||||
onSubmit: (data: Partial<ProxyHost>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
domain_names: host?.domain_names || '',
|
||||
forward_scheme: host?.forward_scheme || 'http',
|
||||
forward_host: host?.forward_host || '',
|
||||
forward_port: host?.forward_port || 80,
|
||||
ssl_forced: host?.ssl_forced ?? true,
|
||||
http2_support: host?.http2_support ?? true,
|
||||
hsts_enabled: host?.hsts_enabled ?? true,
|
||||
hsts_subdomains: host?.hsts_subdomains ?? true,
|
||||
block_exploits: host?.block_exploits ?? true,
|
||||
websocket_support: host?.websocket_support ?? true,
|
||||
advanced_config: host?.advanced_config || '',
|
||||
enabled: host?.enabled ?? true,
|
||||
certificate_id: host?.certificate_id,
|
||||
})
|
||||
|
||||
const { servers: remoteServers } = useRemoteServers()
|
||||
const { domains, createDomain } = useDomains()
|
||||
const { certificates } = useCertificates()
|
||||
const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom')
|
||||
const [selectedDomain, setSelectedDomain] = useState('')
|
||||
const [selectedContainerId, setSelectedContainerId] = useState<string>('')
|
||||
|
||||
// New Domain Popup State
|
||||
const [showDomainPrompt, setShowDomainPrompt] = useState(false)
|
||||
const [pendingDomain, setPendingDomain] = useState('')
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false)
|
||||
|
||||
// Test Connection State
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('cpmp_dont_ask_domain')
|
||||
if (stored === 'true') {
|
||||
setDontAskAgain(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
|
||||
const checkNewDomains = (input: string) => {
|
||||
if (dontAskAgain) return
|
||||
|
||||
const domainList = input.split(',').map(d => d.trim()).filter(d => d)
|
||||
for (const domain of domainList) {
|
||||
const parsed = parse(domain)
|
||||
if (parsed.domain && parsed.domain !== domain) {
|
||||
// It's a subdomain, check if the base domain exists
|
||||
const baseDomain = parsed.domain
|
||||
const exists = domains.some(d => d.name === baseDomain)
|
||||
if (!exists) {
|
||||
setPendingDomain(baseDomain)
|
||||
setShowDomainPrompt(true)
|
||||
return // Only prompt for one at a time
|
||||
}
|
||||
} else if (parsed.domain && parsed.domain === domain) {
|
||||
// It is a base domain, check if it exists
|
||||
const exists = domains.some(d => d.name === domain)
|
||||
if (!exists) {
|
||||
setPendingDomain(domain)
|
||||
setShowDomainPrompt(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleSaveDomain = async () => {
|
||||
try {
|
||||
await createDomain(pendingDomain)
|
||||
setShowDomainPrompt(false)
|
||||
} catch (err) {
|
||||
console.error("Failed to save domain", err)
|
||||
// Optionally show error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDontAskToggle = (checked: boolean) => {
|
||||
setDontAskAgain(checked)
|
||||
localStorage.setItem('cpmp_dont_ask_domain', String(checked))
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!formData.forward_host || !formData.forward_port) return
|
||||
|
||||
setTestStatus('testing')
|
||||
try {
|
||||
await testProxyHostConnection(formData.forward_host, formData.forward_port)
|
||||
setTestStatus('success')
|
||||
// Reset status after 3 seconds
|
||||
setTimeout(() => setTestStatus('idle'), 3000)
|
||||
} catch (err) {
|
||||
console.error("Test connection failed", err)
|
||||
setTestStatus('error')
|
||||
// Reset status after 3 seconds
|
||||
setTimeout(() => setTestStatus('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch containers based on selected source
|
||||
// If 'local', host is undefined (which defaults to local socket in backend)
|
||||
// If remote UUID, we need to find the server and get its host address?
|
||||
// Actually, the backend ListContainers takes a 'host' query param.
|
||||
// If it's a remote server, we should probably pass the UUID or the host address.
|
||||
// Looking at backend/internal/services/docker_service.go, it takes a 'host' string.
|
||||
// If it's a remote server, we need to pass the TCP address (e.g. tcp://1.2.3.4:2375).
|
||||
|
||||
const getDockerHostString = () => {
|
||||
if (connectionSource === 'local') return undefined;
|
||||
if (connectionSource === 'custom') return null;
|
||||
const server = remoteServers.find(s => s.uuid === connectionSource);
|
||||
if (!server) return null;
|
||||
// Construct the Docker host string
|
||||
return `tcp://${server.host}:${server.port}`;
|
||||
}
|
||||
|
||||
const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker(getDockerHostString())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
} catch (err: any) {
|
||||
console.error("Submit error:", err)
|
||||
// Extract error message from axios response if available
|
||||
const message = err.response?.data?.error || err.message || 'Failed to save proxy host'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContainerSelect = (containerId: string) => {
|
||||
setSelectedContainerId(containerId)
|
||||
const container = dockerContainers.find(c => c.id === containerId)
|
||||
if (container) {
|
||||
// Default to internal IP and private port
|
||||
let host = container.ip || container.names[0]
|
||||
let port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80
|
||||
|
||||
// If using a Remote Server, try to use the Host IP and Mapped Public Port
|
||||
if (connectionSource !== 'local' && connectionSource !== 'custom') {
|
||||
const server = remoteServers.find(s => s.uuid === connectionSource)
|
||||
if (server) {
|
||||
// Use the Remote Server's Host IP (e.g. public/tailscale IP)
|
||||
host = server.host
|
||||
|
||||
// Find a mapped public port
|
||||
// We prefer the first mapped port we find
|
||||
const mappedPort = container.ports?.find(p => p.public_port)
|
||||
if (mappedPort) {
|
||||
port = mappedPort.public_port
|
||||
} else {
|
||||
// If no public port is mapped, we can't reach it from outside
|
||||
// But we'll leave the internal port as a fallback, though it likely won't work
|
||||
console.warn('No public port mapped for container on remote server')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newDomainNames = formData.domain_names
|
||||
if (selectedDomain) {
|
||||
const subdomain = container.names[0].replace(/^\//, '')
|
||||
newDomainNames = `${subdomain}.${selectedDomain}`
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
forward_host: host,
|
||||
forward_port: port,
|
||||
forward_scheme: 'http',
|
||||
domain_names: newDomainNames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBaseDomainChange = (domain: string) => {
|
||||
setSelectedDomain(domain)
|
||||
if (selectedContainerId && domain) {
|
||||
const container = dockerContainers.find(c => c.id === selectedContainerId)
|
||||
if (container) {
|
||||
const subdomain = container.names[0].replace(/^\//, '')
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
domain_names: `${subdomain}.${domain}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-800">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{host ? 'Edit Proxy Host' : 'Add Proxy Host'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Docker Container Quick Select */}
|
||||
<div>
|
||||
<label htmlFor="connection-source" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Source
|
||||
</label>
|
||||
<select
|
||||
id="connection-source"
|
||||
value={connectionSource}
|
||||
onChange={e => setConnectionSource(e.target.value)}
|
||||
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="custom">Custom / Manual</option>
|
||||
<option value="local">Local (Docker Socket)</option>
|
||||
{remoteServers
|
||||
.filter(s => s.provider === 'docker' && s.enabled)
|
||||
.map(server => (
|
||||
<option key={server.uuid} value={server.uuid}>
|
||||
{server.name} ({server.host})
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="quick-select-docker" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Containers
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="quick-select-docker"
|
||||
onChange={e => handleContainerSelect(e.target.value)}
|
||||
disabled={dockerLoading || connectionSource === 'custom'}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{connectionSource === 'custom'
|
||||
? 'Select a source to view containers'
|
||||
: (dockerLoading ? 'Loading containers...' : '-- Select a container --')}
|
||||
</option>
|
||||
{dockerContainers.map(container => (
|
||||
<option key={container.id} value={container.id}>
|
||||
{container.names[0]} ({container.image})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{dockerError && connectionSource !== 'custom' && (
|
||||
<p className="text-xs text-red-400 mt-1">
|
||||
Failed to connect: {(dockerError as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Names */}
|
||||
<div className="space-y-4">
|
||||
{domains.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="base-domain" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Base Domain (Auto-fill)
|
||||
</label>
|
||||
<select
|
||||
id="base-domain"
|
||||
value={selectedDomain}
|
||||
onChange={e => handleBaseDomainChange(e.target.value)}
|
||||
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="">-- Select a base domain --</option>
|
||||
{domains.map(domain => (
|
||||
<option key={domain.uuid} value={domain.name}>
|
||||
{domain.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="domain-names" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Domain Names (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
id="domain-names"
|
||||
type="text"
|
||||
required
|
||||
value={formData.domain_names}
|
||||
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
|
||||
onBlur={e => checkNewDomains(e.target.value)}
|
||||
placeholder="example.com, www.example.com"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forward Details */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="forward-scheme" className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
|
||||
<select
|
||||
id="forward-scheme"
|
||||
value={formData.forward_scheme}
|
||||
onChange={e => setFormData({ ...formData, forward_scheme: e.target.value })}
|
||||
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="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="forward-host" className="block text-sm font-medium text-gray-300 mb-2">Host</label>
|
||||
<input
|
||||
id="forward-host"
|
||||
type="text"
|
||||
required
|
||||
value={formData.forward_host}
|
||||
onChange={e => setFormData({ ...formData, forward_host: e.target.value })}
|
||||
placeholder="192.168.1.100"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="forward-port" className="block text-sm font-medium text-gray-300 mb-2">Port</label>
|
||||
<input
|
||||
id="forward-port"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
value={formData.forward_port}
|
||||
onChange={e => setFormData({ ...formData, forward_port: parseInt(e.target.value) })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSL Certificate Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
SSL Certificate
|
||||
</label>
|
||||
<select
|
||||
value={formData.certificate_id || 0}
|
||||
onChange={e => setFormData({ ...formData, certificate_id: parseInt(e.target.value) || null })}
|
||||
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={0}>Request a new SSL Certificate</option>
|
||||
{certificates.filter(c => c.provider === 'custom').map(cert => (
|
||||
<option key={cert.id} value={cert.id}>
|
||||
{cert.name} ({cert.domain})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* SSL & Security Options */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ssl_forced}
|
||||
onChange={e => setFormData({ ...formData, ssl_forced: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Force SSL</span>
|
||||
<div title="Redirects visitors to the secure HTTPS version of your site. You should almost always turn this on to protect your data." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.http2_support}
|
||||
onChange={e => setFormData({ ...formData, http2_support: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HTTP/2 Support</span>
|
||||
<div title="Makes your site load faster by using a modern connection standard. Safe to leave on for most sites." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hsts_enabled}
|
||||
onChange={e => setFormData({ ...formData, hsts_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HSTS Enabled</span>
|
||||
<div title="Tells browsers to REMEMBER to only use HTTPS for this site. Adds extra security but can be tricky if you ever want to go back to HTTP." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.hsts_subdomains}
|
||||
onChange={e => setFormData({ ...formData, hsts_subdomains: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HSTS Subdomains</span>
|
||||
<div title="Applies the HSTS rule to all subdomains (like blog.mysite.com). Only use this if ALL your subdomains are secure." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.block_exploits}
|
||||
onChange={e => setFormData({ ...formData, block_exploits: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Block Exploits</span>
|
||||
<div title="Automatically blocks common hacking attempts. Recommended to keep your site safe." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.websocket_support}
|
||||
onChange={e => setFormData({ ...formData, websocket_support: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Websockets Support</span>
|
||||
<div title="Needed for apps that update in real-time (like chat, notifications, or live status). If your app feels 'broken' or doesn't update, try turning this on." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
<div>
|
||||
<label htmlFor="advanced-config" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Advanced Caddy Config (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="advanced-config"
|
||||
value={formData.advanced_config}
|
||||
onChange={e => setFormData({ ...formData, advanced_config: e.target.value })}
|
||||
placeholder="Additional Caddy directives..."
|
||||
rows={4}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<div className="flex items-center justify-end pb-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-white">Enable Proxy Host</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={loading || testStatus === 'testing' || !formData.forward_host || !formData.forward_port}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50 ${
|
||||
testStatus === 'success' ? 'bg-green-600 hover:bg-green-500 text-white' :
|
||||
testStatus === 'error' ? 'bg-red-600 hover:bg-red-500 text-white' :
|
||||
'bg-gray-700 hover:bg-gray-600 text-white'
|
||||
}`}
|
||||
title="Test connection to the forward host"
|
||||
>
|
||||
{testStatus === 'testing' ? <Loader2 size={18} className="animate-spin" /> :
|
||||
testStatus === 'success' ? <Check size={18} /> :
|
||||
testStatus === 'error' ? <X size={18} /> :
|
||||
'Test Connection'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* New Domain Prompt Modal */}
|
||||
{showDomainPrompt && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4 z-[60]">
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700 max-w-md w-full p-6 shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-4 text-blue-400">
|
||||
<AlertCircle size={24} />
|
||||
<h3 className="text-lg font-semibold text-white">New Base Domain Detected</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">
|
||||
You are using a new base domain: <span className="font-mono font-bold text-white">{pendingDomain}</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
Would you like to save this to your domain list for easier selection in the future?
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dont-ask"
|
||||
checked={dontAskAgain}
|
||||
onChange={e => handleDontAskToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="dont-ask" className="text-sm text-gray-400 select-none">
|
||||
Don't ask me again
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDomainPrompt(false)}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
No, thanks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveDomain}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Yes, save it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Check, X, CircleHelp } from 'lucide-react'
|
||||
import { type RemoteServer, testCustomRemoteServerConnection } from '../api/remoteServers'
|
||||
|
||||
interface Props {
|
||||
server?: RemoteServer
|
||||
onSubmit: (data: Partial<RemoteServer>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function RemoteServerForm({ server, onSubmit, onCancel }: Props) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: server?.name || '',
|
||||
provider: server?.provider || 'generic',
|
||||
host: server?.host || '',
|
||||
port: server?.port ?? 22,
|
||||
username: server?.username || '',
|
||||
enabled: server?.enabled ?? true,
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
name: server?.name || '',
|
||||
provider: server?.provider || 'generic',
|
||||
host: server?.host || '',
|
||||
port: server?.port ?? 22,
|
||||
username: server?.username || '',
|
||||
enabled: server?.enabled ?? true,
|
||||
})
|
||||
}, [server])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSubmit(formData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!formData.host || !formData.port) return
|
||||
setTestStatus('testing')
|
||||
setError(null)
|
||||
try {
|
||||
const result = await testCustomRemoteServerConnection(formData.host, formData.port)
|
||||
if (result.reachable) {
|
||||
setTestStatus('success')
|
||||
setTimeout(() => setTestStatus('idle'), 3000)
|
||||
} else {
|
||||
setTestStatus('error')
|
||||
setError(`Connection failed: ${result.error || 'Unknown error'}`)
|
||||
}
|
||||
} catch {
|
||||
setTestStatus('error')
|
||||
setError('Connection failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-lg w-full">
|
||||
<div className="p-6 border-b border-gray-800">
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{server ? 'Edit Remote Server' : 'Add Remote Server'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="My Production Server"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={e => {
|
||||
const newProvider = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
provider: newProvider,
|
||||
// Set default port for Docker
|
||||
port: newProvider === 'docker' ? 2375 : (newProvider === 'generic' ? 22 : formData.port)
|
||||
})
|
||||
}}
|
||||
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="generic">Generic</option>
|
||||
<option value="docker">Docker</option>
|
||||
<option value="kubernetes">Kubernetes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={e => setFormData({ ...formData, host: e.target.value })}
|
||||
placeholder="192.168.1.100"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={formData.port}
|
||||
onChange={e => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{formData.provider !== 'docker' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enabled}
|
||||
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Enabled</span>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testStatus === 'testing' || !formData.host || !formData.port}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 mr-auto ${
|
||||
testStatus === 'success' ? 'bg-green-600 text-white' :
|
||||
testStatus === 'error' ? 'bg-red-600 text-white' :
|
||||
'bg-gray-700 hover:bg-gray-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{testStatus === 'testing' ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||||
testStatus === 'success' ? <Check className="w-4 h-4" /> :
|
||||
testStatus === 'error' ? <X className="w-4 h-4" /> :
|
||||
<CircleHelp className="w-4 h-4" />}
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : (server ? 'Update' : 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>; // Or a spinner
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSetupStatus } from '../api/setup';
|
||||
|
||||
interface SetupGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SetupGuard: React.FC<SetupGuardProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.setupRequired) {
|
||||
navigate('/setup');
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status?.setupRequired) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { checkUpdates } from '../api/system';
|
||||
|
||||
const SystemStatus: React.FC = () => {
|
||||
// We still query for updates here to keep the cache fresh,
|
||||
// but the UI is now handled by NotificationCenter
|
||||
useQuery({
|
||||
queryKey: ['system-updates'],
|
||||
queryFn: checkUpdates,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SystemStatus;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="sm" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toastCallbacks, Toast } from '../utils/toast'
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const callback = (toast: Toast) => {
|
||||
setToasts(prev => [...prev, toast])
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id))
|
||||
}, 5000)
|
||||
}
|
||||
toastCallbacks.add(callback)
|
||||
return () => {
|
||||
toastCallbacks.delete(callback)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeToast = (id: number) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] animate-slide-in ${
|
||||
toast.type === 'success'
|
||||
? 'bg-green-600 text-white'
|
||||
: toast.type === 'error'
|
||||
? 'bg-red-600 text-white'
|
||||
: toast.type === 'warning'
|
||||
? 'bg-yellow-600 text-white'
|
||||
: 'bg-blue-600 text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{toast.type === 'success' && <span className="mr-2">✓</span>}
|
||||
{toast.type === 'error' && <span className="mr-2">✗</span>}
|
||||
{toast.type === 'warning' && <span className="mr-2">⚠</span>}
|
||||
{toast.type === 'info' && <span className="mr-2">ℹ</span>}
|
||||
{toast.message}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import ImportReviewTable from '../ImportReviewTable'
|
||||
import { mockImportPreview } from '../../test/mockData'
|
||||
|
||||
describe('ImportReviewTable', () => {
|
||||
const mockOnCommit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('displays hosts to import', () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Review Imported Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays conflicts with resolution dropdowns', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays errors', () => {
|
||||
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
errors={errors}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Issues found during parsing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing required field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCommit with resolutions', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const dropdown = screen.getByRole('combobox')
|
||||
fireEvent.change(dropdown, { target: { value: 'overwrite' } })
|
||||
|
||||
const commitButton = screen.getByText('Commit Import')
|
||||
fireEvent.click(commitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommit).toHaveBeenCalledWith({
|
||||
'test.example.com': 'overwrite',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Back'))
|
||||
expect(mockOnCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows conflict indicator on conflicting hosts', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Layout from '../Layout'
|
||||
import { ThemeProvider } from '../../context/ThemeContext'
|
||||
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
// Mock AuthContext
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
logout: mockLogout,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API
|
||||
vi.mock('../../api/health', () => ({
|
||||
checkHealth: vi.fn().mockResolvedValue({
|
||||
version: '0.1.0',
|
||||
git_commit: 'abcdef1',
|
||||
}),
|
||||
}))
|
||||
|
||||
const renderWithProviders = (children: ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Layout', () => {
|
||||
it('renders the application title', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('CPM+')[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all navigation items', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children content', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('test-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays version information', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Version 0.1.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when logout button is clicked', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const logoutButton = screen.getByText('Logout')
|
||||
fireEvent.click(logoutButton)
|
||||
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles sidebar on mobile', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// Initially sidebar is hidden on mobile (by CSS class, but we can check if the toggle button exists)
|
||||
// The toggle button has text '☰' when closed
|
||||
const toggleButton = screen.getByText('☰')
|
||||
fireEvent.click(toggleButton)
|
||||
|
||||
// Now it should show '✕'
|
||||
expect(screen.getByText('✕')).toBeInTheDocument()
|
||||
|
||||
// And the overlay should be present
|
||||
// The overlay has class 'fixed inset-0 bg-black/50 z-20 lg:hidden'
|
||||
// We can find it by class or just assume if we click it it closes
|
||||
// Let's try to click the overlay. It doesn't have text.
|
||||
// We can query by selector if we add a test id or just rely on structure.
|
||||
// But let's just click the toggle button again to close.
|
||||
fireEvent.click(screen.getByText('✕'))
|
||||
expect(screen.getByText('☰')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,171 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import NotificationCenter from '../NotificationCenter'
|
||||
import * as api from '../../api/system'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/system', () => ({
|
||||
getNotifications: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockNotifications: api.Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
title: 'Info Notification',
|
||||
message: 'This is an info message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'success',
|
||||
title: 'Success Notification',
|
||||
message: 'This is a success message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Warning Notification',
|
||||
message: 'This is a warning message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'error',
|
||||
title: 'Error Notification',
|
||||
message: 'This is an error message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T13:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('NotificationCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '0.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders bell icon and unread count', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens notification panel on click', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
fireEvent.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Success Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Warning Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error Notification')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays empty state when no notifications', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue([])
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
fireEvent.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No new notifications')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks single notification as read', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markNotificationRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i })
|
||||
fireEvent.click(closeButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markNotificationRead).toHaveBeenCalledWith('1', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mark all read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Mark all read'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markAllNotificationsRead).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes panel when clicking outside', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('notification-backdrop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import { mockRemoteServers } from '../../test/mockData'
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: mockRemoteServers,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({
|
||||
containers: [
|
||||
{
|
||||
id: 'container-123',
|
||||
names: ['my-app'],
|
||||
image: 'nginx:latest',
|
||||
state: 'running',
|
||||
status: 'Up 2 hours',
|
||||
network: 'bridge',
|
||||
ip: '172.17.0.2',
|
||||
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({
|
||||
domains: [
|
||||
{ uuid: 'domain-1', name: 'existing.com' }
|
||||
],
|
||||
createDomain: vi.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'Cert 1', domain: 'example.com', provider: 'custom' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
import { testProxyHostConnection } from '../../api/proxyHosts'
|
||||
|
||||
describe('ProxyHostForm', () => {
|
||||
const mockOnSubmit = vi.fn((_data: any) => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('handles scheme selection', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find scheme select - it defaults to HTTP
|
||||
// We can find it by label "Scheme"
|
||||
const schemeSelect = screen.getByLabelText('Scheme')
|
||||
fireEvent.change(schemeSelect, { target: { value: 'https' } })
|
||||
|
||||
expect(schemeSelect).toHaveValue('https')
|
||||
})
|
||||
|
||||
it('prompts to save new base domain', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
|
||||
// Enter a subdomain of a new base domain
|
||||
fireEvent.change(domainInput, { target: { value: 'sub.newdomain.com' } })
|
||||
fireEvent.blur(domainInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('newdomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click "Yes, save it"
|
||||
fireEvent.click(screen.getByText('Yes, save it'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('respects "Dont ask me again" for new domains', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
|
||||
// Trigger prompt
|
||||
fireEvent.change(domainInput, { target: { value: 'sub.another.com' } })
|
||||
fireEvent.blur(domainInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check "Don't ask me again"
|
||||
fireEvent.click(screen.getByLabelText("Don't ask me again"))
|
||||
|
||||
// Click "No, thanks"
|
||||
fireEvent.click(screen.getByText('No, thanks'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Try another new domain - should not prompt
|
||||
fireEvent.change(domainInput, { target: { value: 'sub.yetanother.com' } })
|
||||
fireEvent.blur(domainInput)
|
||||
|
||||
// Should not see prompt
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tests connection successfully', async () => {
|
||||
(testProxyHostConnection as any).mockResolvedValue({})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields for test connection
|
||||
fireEvent.change(screen.getByLabelText(/^Host$/), { target: { value: '10.0.0.5' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Port$/), { target: { value: '80' } })
|
||||
|
||||
const testBtn = screen.getByTitle('Test connection to the forward host')
|
||||
fireEvent.click(testBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProxyHostConnection).toHaveBeenCalledWith('10.0.0.5', 80)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles connection test failure', async () => {
|
||||
(testProxyHostConnection as any).mockRejectedValue(new Error('Connection failed'))
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^Host$/), { target: { value: '10.0.0.5' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Port$/), { target: { value: '80' } })
|
||||
|
||||
const testBtn = screen.getByTitle('Test connection to the forward host')
|
||||
fireEvent.click(testBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProxyHostConnection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should show error state (red button) - we can check class or icon
|
||||
// The button changes class to bg-red-600
|
||||
await waitFor(() => {
|
||||
expect(testBtn).toHaveClass('bg-red-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles base domain selection', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Base Domain (Auto-fill)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Base Domain (Auto-fill)'), { target: { value: 'existing.com' } })
|
||||
|
||||
// Should not update domain names yet as no container selected
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('')
|
||||
|
||||
// Select container then base domain
|
||||
fireEvent.change(screen.getByLabelText('Containers'), { target: { value: 'container-123' } })
|
||||
fireEvent.change(screen.getByLabelText('Base Domain (Auto-fill)'), { target: { value: 'existing.com' } })
|
||||
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
|
||||
})
|
||||
})
|
||||
@@ -1,194 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import RemoteServerForm from '../RemoteServerForm'
|
||||
import * as remoteServersApi from '../../api/remoteServers'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/remoteServers', () => ({
|
||||
testRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080' })),
|
||||
testCustomRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080', reachable: true })),
|
||||
}))
|
||||
|
||||
describe('RemoteServerForm', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders create form', () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('renders edit form with pre-filled data', () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
username: 'admin',
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows test connection button in create and edit mode', () => {
|
||||
const { rerender } = render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).toBeInTheDocument()
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: false,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
rerender(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(mockOnCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits form with correct data', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('My Production Server')
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.100')
|
||||
const portInput = screen.getByDisplayValue('22')
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'New Server' } })
|
||||
fireEvent.change(hostInput, { target: { value: '10.0.0.5' } })
|
||||
fireEvent.change(portInput, { target: { value: '9090' } })
|
||||
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'New Server',
|
||||
host: '10.0.0.5',
|
||||
port: 9090,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles provider selection', () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const providerSelect = screen.getByDisplayValue('Generic')
|
||||
fireEvent.change(providerSelect, { target: { value: 'docker' } })
|
||||
|
||||
expect(providerSelect).toHaveValue('docker')
|
||||
})
|
||||
|
||||
it('handles submission error', async () => {
|
||||
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
fireEvent.change(screen.getByPlaceholderText('My Production Server'), { target: { value: 'Test Server' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.100'), { target: { value: '10.0.0.1' } })
|
||||
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submission failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection success', async () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const testButton = screen.getByText('Test Connection')
|
||||
fireEvent.click(testButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for success state (green background)
|
||||
expect(testButton).toHaveClass('bg-green-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection failure', async () => {
|
||||
// Override mock for this test
|
||||
vi.mocked(remoteServersApi.testCustomRemoteServerConnection).mockRejectedValueOnce(new Error('Connection failed'))
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Test Connection'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import SystemStatus from '../SystemStatus'
|
||||
import * as systemApi from '../../api/system'
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SystemStatus', () => {
|
||||
it('calls checkUpdates on mount', async () => {
|
||||
vi.mocked(systemApi.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '1.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
|
||||
renderWithClient(<SystemStatus />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(systemApi.checkUpdates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
isLoading?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-700 text-white hover:bg-gray-600 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-400 hover:text-white hover:bg-gray-800 focus:ring-gray-500',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ReactNode, HTMLAttributes } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
description?: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function Card({ children, className, title, description, footer, ...props }: CardProps) {
|
||||
return (
|
||||
<div className={clsx('bg-dark-card rounded-lg border border-gray-800 overflow-hidden', className)} {...props}>
|
||||
{(title || description) && (
|
||||
<div className="px-6 py-4 border-b border-gray-800">
|
||||
{title && <h3 className="text-lg font-medium text-white">{title}</h3>}
|
||||
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="px-6 py-4 bg-gray-900/50 border-t border-gray-800">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { InputHTMLAttributes, forwardRef, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, className, type, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const isPassword = type === 'password'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="block text-sm font-medium text-gray-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={ref}
|
||||
type={isPassword ? (showPassword ? 'text' : 'password') : type}
|
||||
className={clsx(
|
||||
'w-full bg-gray-900 border rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors',
|
||||
error
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-700 focus:ring-blue-500 focus:border-blue-500',
|
||||
isPassword && 'pr-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 focus:outline-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, onCheckedChange, onChange, ...props }, ref) => {
|
||||
return (
|
||||
<label className={cn("relative inline-flex items-center cursor-pointer", className)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
onChange={(e) => {
|
||||
onChange?.(e)
|
||||
onCheckedChange?.(e.target.checked)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
)
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export { Switch }
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import client from '../api/client';
|
||||
import { AuthContext, User } from './AuthContextValue';
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await client.get('/auth/me');
|
||||
setUser(response.data);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async () => {
|
||||
// Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch
|
||||
// Actually, if backend sets cookie, we just need to fetch /auth/me
|
||||
try {
|
||||
const response = await client.get<User>('/auth/me');
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await client.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
}
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const changePassword = async (oldPassword: string, newPassword: string) => {
|
||||
await client.post('/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-logout logic
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const resetTimer = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
console.log('Auto-logging out due to inactivity');
|
||||
logout();
|
||||
}, TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Initial timer start
|
||||
resetTimer();
|
||||
|
||||
// Event listeners for activity
|
||||
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||
const handleActivity = () => resetTimer();
|
||||
|
||||
events.forEach(event => {
|
||||
window.addEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, handleActivity);
|
||||
});
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, changePassword, isAuthenticated: !!user, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export interface User {
|
||||
user_id: number;
|
||||
role: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
login: () => Promise<void>;
|
||||
logout: () => void;
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useEffect, useState, ReactNode } from 'react'
|
||||
import { ThemeContext, Theme } from './ThemeContextValue'
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme')
|
||||
return (saved as Theme) || 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
export interface ThemeContextType {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useImport } from '../useImport'
|
||||
import * as api from '../../api/import'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/import', () => ({
|
||||
uploadCaddyfile: vi.fn(),
|
||||
getImportPreview: vi.fn(),
|
||||
commitImport: vi.fn(),
|
||||
cancelImport: vi.fn(),
|
||||
getImportStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useImport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with no active session', async () => {
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.session).toBeNull()
|
||||
})
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('uploads content and creates session', async () => {
|
||||
const mockSession = {
|
||||
id: 'session-1',
|
||||
state: 'reviewing' as const,
|
||||
created_at: '2025-01-18T10:00:00Z',
|
||||
updated_at: '2025-01-18T10:00:00Z',
|
||||
}
|
||||
|
||||
const mockPreviewData = {
|
||||
hosts: [{ domain_names: 'test.com' }],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
session: mockSession,
|
||||
preview: mockPreviewData,
|
||||
}
|
||||
|
||||
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
|
||||
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession })
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('example.com { reverse_proxy localhost:8080 }')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
expect(api.uploadCaddyfile).toHaveBeenCalledWith('example.com { reverse_proxy localhost:8080 }')
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('handles upload errors', async () => {
|
||||
const mockError = new Error('Upload failed')
|
||||
vi.mocked(api.uploadCaddyfile).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
let threw = false
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.upload('invalid')
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
})
|
||||
expect(threw).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Upload failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('commits import with resolutions', async () => {
|
||||
const mockSession = {
|
||||
id: 'session-2',
|
||||
state: 'reviewing' as const,
|
||||
created_at: '2025-01-18T10:00:00Z',
|
||||
updated_at: '2025-01-18T10:00:00Z',
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
session: mockSession,
|
||||
preview: { hosts: [], conflicts: [], errors: [] },
|
||||
}
|
||||
|
||||
let isCommitted = false
|
||||
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
|
||||
vi.mocked(api.getImportStatus).mockImplementation(async () => {
|
||||
if (isCommitted) return { has_pending: false }
|
||||
return { has_pending: true, session: mockSession }
|
||||
})
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
|
||||
vi.mocked(api.commitImport).mockImplementation(async () => {
|
||||
isCommitted = true
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('test')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.commit({ 'test.com': 'skip' })
|
||||
})
|
||||
|
||||
expect(api.commitImport).toHaveBeenCalledWith('session-2', { 'test.com': 'skip' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels active import session', async () => {
|
||||
const mockSession = {
|
||||
id: 'session-3',
|
||||
state: 'reviewing' as const,
|
||||
created_at: '2025-01-18T10:00:00Z',
|
||||
updated_at: '2025-01-18T10:00:00Z',
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
session: mockSession,
|
||||
preview: { hosts: [], conflicts: [], errors: [] },
|
||||
}
|
||||
|
||||
let isCancelled = false
|
||||
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
|
||||
vi.mocked(api.getImportStatus).mockImplementation(async () => {
|
||||
if (isCancelled) return { has_pending: false }
|
||||
return { has_pending: true, session: mockSession }
|
||||
})
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
|
||||
vi.mocked(api.cancelImport).mockImplementation(async () => {
|
||||
isCancelled = true
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('test')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(api.cancelImport).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles commit errors', async () => {
|
||||
const mockSession = {
|
||||
id: 'session-4',
|
||||
state: 'reviewing' as const,
|
||||
created_at: '2025-01-18T10:00:00Z',
|
||||
updated_at: '2025-01-18T10:00:00Z',
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
session: mockSession,
|
||||
preview: { hosts: [], conflicts: [], errors: [] },
|
||||
}
|
||||
|
||||
vi.mocked(api.uploadCaddyfile).mockResolvedValue(mockResponse)
|
||||
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession })
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue(mockResponse)
|
||||
|
||||
const mockError = new Error('Commit failed')
|
||||
vi.mocked(api.commitImport).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('test')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
let threw = false
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.commit({})
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
})
|
||||
expect(threw).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Commit failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,216 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useProxyHosts } from '../useProxyHosts'
|
||||
import * as api from '../../api/proxyHosts'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
}))
|
||||
|
||||
const createMockHost = (overrides: Partial<api.ProxyHost> = {}): api.ProxyHost => ({
|
||||
uuid: '1',
|
||||
domain_names: 'test.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useProxyHosts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads proxy hosts on mount', async () => {
|
||||
const mockHosts = [
|
||||
createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }),
|
||||
createMockHost({ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }),
|
||||
]
|
||||
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue(mockHosts)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.hosts).toEqual([])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.hosts).toEqual(mockHosts)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(api.getProxyHosts).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles loading errors', async () => {
|
||||
const mockError = new Error('Failed to fetch')
|
||||
vi.mocked(api.getProxyHosts).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to fetch')
|
||||
expect(result.current.hosts).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a new proxy host', async () => {
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([])
|
||||
const newHost = { domain_names: 'new.com', forward_host: 'localhost', forward_port: 9000 }
|
||||
const createdHost = createMockHost({ uuid: '3', ...newHost, enabled: true })
|
||||
|
||||
vi.mocked(api.createProxyHost).mockImplementation(async () => {
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([createdHost])
|
||||
return createdHost
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createHost(newHost)
|
||||
})
|
||||
|
||||
expect(api.createProxyHost).toHaveBeenCalledWith(newHost)
|
||||
await waitFor(() => {
|
||||
expect(result.current.hosts).toContainEqual(createdHost)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing proxy host', async () => {
|
||||
const existingHost = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 })
|
||||
let hosts = [existingHost]
|
||||
vi.mocked(api.getProxyHosts).mockImplementation(() => Promise.resolve(hosts))
|
||||
|
||||
vi.mocked(api.updateProxyHost).mockImplementation(async (_, data) => {
|
||||
hosts = [{ ...existingHost, ...data }]
|
||||
return hosts[0]
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateHost('1', { domain_names: 'updated.com' })
|
||||
})
|
||||
|
||||
expect(api.updateProxyHost).toHaveBeenCalledWith('1', { domain_names: 'updated.com' })
|
||||
await waitFor(() => {
|
||||
expect(result.current.hosts[0].domain_names).toBe('updated.com')
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a proxy host', async () => {
|
||||
const hosts = [
|
||||
createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }),
|
||||
createMockHost({ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 }),
|
||||
]
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue(hosts)
|
||||
vi.mocked(api.deleteProxyHost).mockImplementation(async (uuid) => {
|
||||
const remaining = hosts.filter(h => h.uuid !== uuid)
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue(remaining)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteHost('1')
|
||||
})
|
||||
|
||||
expect(api.deleteProxyHost).toHaveBeenCalledWith('1')
|
||||
await waitFor(() => {
|
||||
expect(result.current.hosts).toHaveLength(1)
|
||||
expect(result.current.hosts[0].uuid).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles create errors', async () => {
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([])
|
||||
const mockError = new Error('Failed to create')
|
||||
vi.mocked(api.createProxyHost).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.createHost({ domain_names: 'test.com', forward_host: 'localhost', forward_port: 8080 })).rejects.toThrow('Failed to create')
|
||||
})
|
||||
|
||||
it('handles update errors', async () => {
|
||||
const host = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 })
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([host])
|
||||
const mockError = new Error('Failed to update')
|
||||
vi.mocked(api.updateProxyHost).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.updateHost('1', { domain_names: 'updated.com' })).rejects.toThrow('Failed to update')
|
||||
})
|
||||
|
||||
it('handles delete errors', async () => {
|
||||
const host = createMockHost({ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 })
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([host])
|
||||
const mockError = new Error('Failed to delete')
|
||||
vi.mocked(api.deleteProxyHost).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.deleteHost('1')).rejects.toThrow('Failed to delete')
|
||||
})
|
||||
})
|
||||
@@ -1,242 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useRemoteServers } from '../useRemoteServers'
|
||||
import * as api from '../../api/remoteServers'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/remoteServers', () => ({
|
||||
getRemoteServers: vi.fn(),
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
testRemoteServerConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
const createMockServer = (overrides: Partial<api.RemoteServer> = {}): api.RemoteServer => ({
|
||||
uuid: '1',
|
||||
name: 'Server 1',
|
||||
provider: 'generic',
|
||||
host: 'localhost',
|
||||
port: 8080,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useRemoteServers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads all remote servers on mount', async () => {
|
||||
const mockServers = [
|
||||
createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }),
|
||||
createMockServer({ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }),
|
||||
]
|
||||
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue(mockServers)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.servers).toEqual([])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.servers).toEqual(mockServers)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(api.getRemoteServers).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles loading errors', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
vi.mocked(api.getRemoteServers).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Network error')
|
||||
expect(result.current.servers).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a new remote server', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const newServer = { name: 'New Server', host: 'new.local', port: 5000, provider: 'generic' }
|
||||
const createdServer = createMockServer({ uuid: '4', ...newServer, enabled: true })
|
||||
|
||||
vi.mocked(api.createRemoteServer).mockImplementation(async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([createdServer])
|
||||
return createdServer
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createServer(newServer)
|
||||
})
|
||||
|
||||
expect(api.createRemoteServer).toHaveBeenCalledWith(newServer)
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers).toContainEqual(createdServer)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing remote server', async () => {
|
||||
const existingServer = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true })
|
||||
let servers = [existingServer]
|
||||
vi.mocked(api.getRemoteServers).mockImplementation(() => Promise.resolve(servers))
|
||||
|
||||
vi.mocked(api.updateRemoteServer).mockImplementation(async (_, data) => {
|
||||
servers = [{ ...existingServer, ...data }]
|
||||
return servers[0]
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateServer('1', { name: 'Updated Server' })
|
||||
})
|
||||
|
||||
expect(api.updateRemoteServer).toHaveBeenCalledWith('1', { name: 'Updated Server' })
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers[0].name).toBe('Updated Server')
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a remote server', async () => {
|
||||
const servers = [
|
||||
createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }),
|
||||
createMockServer({ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false }),
|
||||
]
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue(servers)
|
||||
vi.mocked(api.deleteRemoteServer).mockImplementation(async (uuid) => {
|
||||
const remaining = servers.filter(s => s.uuid !== uuid)
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue(remaining)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteServer('1')
|
||||
})
|
||||
|
||||
expect(api.deleteRemoteServer).toHaveBeenCalledWith('1')
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers).toHaveLength(1)
|
||||
expect(result.current.servers[0].uuid).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
it('tests server connection', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const testResult = { reachable: true, address: 'localhost:8080' }
|
||||
vi.mocked(api.testRemoteServerConnection).mockResolvedValue(testResult)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
const response = await result.current.testConnection('1')
|
||||
|
||||
expect(api.testRemoteServerConnection).toHaveBeenCalledWith('1')
|
||||
expect(response).toEqual(testResult)
|
||||
})
|
||||
|
||||
it('handles create errors', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const mockError = new Error('Failed to create')
|
||||
vi.mocked(api.createRemoteServer).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.createServer({ name: 'Test', host: 'localhost', port: 8080 })).rejects.toThrow('Failed to create')
|
||||
})
|
||||
|
||||
it('handles update errors', async () => {
|
||||
const server = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true })
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([server])
|
||||
const mockError = new Error('Failed to update')
|
||||
vi.mocked(api.updateRemoteServer).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.updateServer('1', { name: 'Updated Server' })).rejects.toThrow('Failed to update')
|
||||
})
|
||||
|
||||
it('handles delete errors', async () => {
|
||||
const server = createMockServer({ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true })
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([server])
|
||||
const mockError = new Error('Failed to delete')
|
||||
vi.mocked(api.deleteRemoteServer).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.deleteServer('1')).rejects.toThrow('Failed to delete')
|
||||
})
|
||||
|
||||
it('handles connection test errors', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const mockError = new Error('Connection failed')
|
||||
vi.mocked(api.testRemoteServerConnection).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.testConnection('1')).rejects.toThrow('Connection failed')
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useTheme } from '../useTheme'
|
||||
|
||||
describe('useTheme', () => {
|
||||
it('throws error when used outside ThemeProvider', () => {
|
||||
// Suppress console.error for this test as React logs the error
|
||||
const consoleSpy = vi.spyOn(console, 'error')
|
||||
consoleSpy.mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTheme())
|
||||
}).toThrow('useTheme must be used within a ThemeProvider')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from '../context/AuthContextValue';
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCertificates } from '../api/certificates'
|
||||
|
||||
export function useCertificates() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['certificates'],
|
||||
queryFn: getCertificates,
|
||||
})
|
||||
|
||||
return {
|
||||
certificates: data || [],
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { dockerApi } from '../api/docker'
|
||||
|
||||
export function useDocker(host?: string | null) {
|
||||
const {
|
||||
data: containers = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['docker-containers', host],
|
||||
queryFn: () => dockerApi.listContainers(host || undefined),
|
||||
enabled: host !== null, // Disable if host is explicitly null
|
||||
retry: 1, // Don't retry too much if docker is not available
|
||||
})
|
||||
|
||||
return {
|
||||
containers,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '../api/domains'
|
||||
|
||||
export function useDomains() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: domains = [], isLoading, isFetching, error } = useQuery({
|
||||
queryKey: ['domains'],
|
||||
queryFn: api.getDomains,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createDomain,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteDomain,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
domains,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
createDomain: createMutation.mutateAsync,
|
||||
deleteDomain: deleteMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadCaddyfile,
|
||||
getImportPreview,
|
||||
commitImport,
|
||||
cancelImport,
|
||||
getImportStatus,
|
||||
ImportSession,
|
||||
ImportPreview
|
||||
} from '../api/import';
|
||||
|
||||
export const QUERY_KEY = ['import-session'];
|
||||
|
||||
export function useImport() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Poll for status if we think there's an active session
|
||||
const statusQuery = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: getImportStatus,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
// Poll if we have a pending session in reviewing state
|
||||
if (data?.has_pending && data?.session?.state === 'reviewing') {
|
||||
return 3000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['import-preview'],
|
||||
queryFn: getImportPreview,
|
||||
enabled: !!statusQuery.data?.has_pending && (statusQuery.data?.session?.state === 'reviewing' || statusQuery.data?.session?.state === 'pending'),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (content: string) => uploadCaddyfile(content),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
|
||||
},
|
||||
});
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: (resolutions: Record<string, string>) => {
|
||||
const sessionId = statusQuery.data?.session?.id;
|
||||
if (!sessionId) throw new Error("No active session");
|
||||
return commitImport(sessionId, resolutions);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
|
||||
// Also invalidate proxy hosts as they might have changed
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => cancelImport(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session: statusQuery.data?.session || null,
|
||||
preview: previewQuery.data || null,
|
||||
loading: statusQuery.isLoading || uploadMutation.isPending || commitMutation.isPending || cancelMutation.isPending,
|
||||
error: (statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error)
|
||||
? ((statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error) as Error).message
|
||||
: null,
|
||||
upload: uploadMutation.mutateAsync,
|
||||
commit: commitMutation.mutateAsync,
|
||||
cancel: cancelMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ImportSession, ImportPreview };
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getProxyHosts,
|
||||
createProxyHost,
|
||||
updateProxyHost,
|
||||
deleteProxyHost,
|
||||
ProxyHost
|
||||
} from '../api/proxyHosts';
|
||||
|
||||
export const QUERY_KEY = ['proxy-hosts'];
|
||||
|
||||
export function useProxyHosts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: getProxyHosts,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (host: Partial<ProxyHost>) => createProxyHost(host),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: Partial<ProxyHost> }) =>
|
||||
updateProxyHost(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (uuid: string) => deleteProxyHost(uuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hosts: query.data || [],
|
||||
loading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error ? (query.error as Error).message : null,
|
||||
createHost: createMutation.mutateAsync,
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => updateMutation.mutateAsync({ uuid, data }),
|
||||
deleteHost: deleteMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ProxyHost };
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getRemoteServers,
|
||||
createRemoteServer,
|
||||
updateRemoteServer,
|
||||
deleteRemoteServer,
|
||||
testRemoteServerConnection,
|
||||
RemoteServer
|
||||
} from '../api/remoteServers';
|
||||
|
||||
export const QUERY_KEY = ['remote-servers'];
|
||||
|
||||
export function useRemoteServers(enabledOnly = false) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...QUERY_KEY, { enabled: enabledOnly }],
|
||||
queryFn: () => getRemoteServers(enabledOnly),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (server: Partial<RemoteServer>) => createRemoteServer(server),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: Partial<RemoteServer> }) =>
|
||||
updateRemoteServer(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (uuid: string) => deleteRemoteServer(uuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const testConnectionMutation = useMutation({
|
||||
mutationFn: (uuid: string) => testRemoteServerConnection(uuid),
|
||||
});
|
||||
|
||||
return {
|
||||
servers: query.data || [],
|
||||
loading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error ? (query.error as Error).message : null,
|
||||
createServer: createMutation.mutateAsync,
|
||||
updateServer: (uuid: string, data: Partial<RemoteServer>) => updateMutation.mutateAsync({ uuid, data }),
|
||||
deleteServer: deleteMutation.mutateAsync,
|
||||
testConnection: testConnectionMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isTestingConnection: testConnectionMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export type { RemoteServer };
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeContext } from '../context/ThemeContextValue'
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@layer utilities {
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #0f172a;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
zoom: 0.75;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,465 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../api/user'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { Copy, RefreshCw, Shield, Mail, User, AlertTriangle } from 'lucide-react'
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
|
||||
import { isValidEmail } from '../utils/validation'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function Account() {
|
||||
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 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
|
||||
useEffect(() => {
|
||||
if (settings && profile) {
|
||||
const savedEmail = settings['caddy.email']
|
||||
if (savedEmail && savedEmail !== profile.email) {
|
||||
setCertEmail(savedEmail)
|
||||
setUseUserEmail(false)
|
||||
} else {
|
||||
setCertEmail(profile.email)
|
||||
setUseUserEmail(true)
|
||||
}
|
||||
}
|
||||
}, [settings, profile])
|
||||
|
||||
// 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('Profile updated successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to update profile: ${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('Certificate email updated')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to update certificate email: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: regenerateApiKey,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] })
|
||||
toast.success('API Key regenerated successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to regenerate API key: ${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'
|
||||
})
|
||||
}
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await changePassword(oldPassword, newPassword)
|
||||
toast.success('Password updated successfully')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyApiKey = () => {
|
||||
if (profile?.api_key) {
|
||||
navigator.clipboard.writeText(profile.api_key)
|
||||
toast.success('API Key copied to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return <div className="p-4">Loading profile...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Profile</h2>
|
||||
</div>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
error={emailValid === false ? 'Please enter a valid email address' : undefined}
|
||||
className={emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={updateProfileMutation.isPending} disabled={emailValid === false}>
|
||||
Save Profile
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Certificate Email Settings */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Mail className="w-5 h-5 text-purple-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Certificate Email</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
This email is used for Let's Encrypt notifications and recovery.
|
||||
</p>
|
||||
<form onSubmit={handleUpdateCertEmail} className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useUserEmail"
|
||||
checked={useUserEmail}
|
||||
onChange={(e) => {
|
||||
setUseUserEmail(e.target.checked)
|
||||
if (e.target.checked && profile) {
|
||||
setCertEmail(profile.email)
|
||||
}
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="useUserEmail" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Use my account email ({profile?.email})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!useUserEmail && (
|
||||
<Input
|
||||
label="Custom Email"
|
||||
type="email"
|
||||
value={certEmail}
|
||||
onChange={(e) => setCertEmail(e.target.value)}
|
||||
required={!useUserEmail}
|
||||
error={certEmailValid === false ? 'Please enter a valid email address' : undefined}
|
||||
className={certEmailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={updateSettingMutation.isPending} disabled={!useUserEmail && certEmailValid === false}>
|
||||
Save Certificate Email
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
|
||||
</div>
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
error={confirmPassword && newPassword !== confirmPassword ? 'Passwords do not match' : undefined}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={loading}>
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* API Key */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1 bg-yellow-100 dark:bg-yellow-900/30 rounded">
|
||||
<span className="text-lg">🔑</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">API Key</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use this key to authenticate with the API programmatically. Keep it secret!
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={profile?.api_key || ''}
|
||||
readOnly
|
||||
className="font-mono text-sm bg-gray-50 dark:bg-gray-900"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={copyApiKey} title="Copy to clipboard">
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
isLoading={regenerateMutation.isPending}
|
||||
title="Regenerate API Key"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password Prompt Modal */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 text-blue-600 dark:text-blue-500">
|
||||
<Shield className="w-6 h-6" />
|
||||
<h3 className="text-lg font-bold">Confirm Password</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Please enter your current password to confirm these changes.
|
||||
</p>
|
||||
<form onSubmit={handlePasswordPromptSubmit} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current Password"
|
||||
value={confirmPasswordForUpdate}
|
||||
onChange={(e) => setConfirmPasswordForUpdate(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button type="submit" className="w-full" isLoading={updateProfileMutation.isPending}>
|
||||
Confirm & Update
|
||||
</Button>
|
||||
<Button type="button" onClick={() => {
|
||||
setShowPasswordPrompt(false)
|
||||
setConfirmPasswordForUpdate('')
|
||||
setPendingProfileUpdate(null)
|
||||
}} variant="ghost" className="w-full text-gray-500">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Update Confirmation Modal */}
|
||||
{showEmailConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4 text-yellow-600 dark:text-yellow-500">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
<h3 className="text-lg font-bold">Update Certificate Email?</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
You are changing your account email to <strong>{email}</strong>.
|
||||
Do you want to use this new email for SSL certificates as well?
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={() => confirmEmailUpdate(true)} className="w-full">
|
||||
Yes, update certificate email too
|
||||
</Button>
|
||||
<Button onClick={() => confirmEmailUpdate(false)} variant="secondary" className="w-full">
|
||||
No, keep using {previousEmail || certEmail}
|
||||
</Button>
|
||||
<Button onClick={() => setShowEmailConfirmModal(false)} variant="ghost" className="w-full text-gray-500">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getBackups, createBackup, restoreBackup, deleteBackup, BackupFile } from '../api/backups'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { Loader2, Download, RotateCcw, Plus, Archive, Trash2, Save } from 'lucide-react'
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export default function Backups() {
|
||||
const queryClient = useQueryClient()
|
||||
const [interval, setInterval] = useState('7')
|
||||
const [retention, setRetention] = useState('30')
|
||||
|
||||
// Fetch Backups
|
||||
const { data: backups, isLoading: isLoadingBackups } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: getBackups,
|
||||
})
|
||||
|
||||
// Fetch Settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Update local state when settings load
|
||||
useState(() => {
|
||||
if (settings) {
|
||||
if (settings['backup.interval']) setInterval(settings['backup.interval'])
|
||||
if (settings['backup.retention']) setRetention(settings['backup.retention'])
|
||||
}
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createBackup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] })
|
||||
toast.success('Backup created successfully')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to create backup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: restoreBackup,
|
||||
onSuccess: () => {
|
||||
toast.success('Backup restored successfully. Please restart the container.')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to restore backup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteBackup,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] })
|
||||
toast.success('Backup deleted successfully')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to delete backup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting('backup.interval', interval, 'system', 'int')
|
||||
await updateSetting('backup.retention', retention, 'system', 'int')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success('Backup settings saved')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleDownload = (filename: string) => {
|
||||
// Trigger download via browser navigation
|
||||
// The browser will send the auth cookie automatically
|
||||
window.location.href = `/api/v1/backups/${filename}/download`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Archive className="w-8 h-8" />
|
||||
Backups
|
||||
</h1>
|
||||
|
||||
{/* Settings Section */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Configuration</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<Input
|
||||
label="Backup Interval (Days)"
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
<Input
|
||||
label="Retention Period (Days)"
|
||||
type="number"
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
className="mb-0.5"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => createMutation.mutate()} isLoading={createMutation.isPending}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Backup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-medium">Filename</th>
|
||||
<th className="px-6 py-3 font-medium">Size</th>
|
||||
<th className="px-6 py-3 font-medium">Created At</th>
|
||||
<th className="px-6 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{isLoadingBackups ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto text-blue-500" />
|
||||
</td>
|
||||
</tr>
|
||||
) : backups?.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||
No backups found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
backups?.map((backup: BackupFile) => (
|
||||
<tr key={backup.filename} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
|
||||
{backup.filename}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
||||
{formatSize(backup.size)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
||||
{new Date(backup.time).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(backup.filename)}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to restore this backup? Current data will be overwritten.')) {
|
||||
restoreMutation.mutate(backup.filename)
|
||||
}
|
||||
}}
|
||||
isLoading={restoreMutation.isPending}
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this backup?')) {
|
||||
deleteMutation.mutate(backup.filename)
|
||||
}
|
||||
}}
|
||||
isLoading={deleteMutation.isPending}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import CertificateList from '../components/CertificateList'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { uploadCertificate } from '../api/certificates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
export default function Certificates() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [certFile, setCertFile] = useState<File | null>(null)
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!certFile || !keyFile) throw new Error('Files required')
|
||||
await uploadCertificate(name, certFile, keyFile)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
setIsModalOpen(false)
|
||||
setName('')
|
||||
setCertFile(null)
|
||||
setKeyFile(null)
|
||||
toast.success('Certificate uploaded successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to upload certificate: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
uploadMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Certificates</h1>
|
||||
<p className="text-gray-400">
|
||||
View and manage SSL certificates.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Certificate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CertificateList />
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-white">Upload Certificate</h2>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Friendly Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. My Custom Cert"
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Certificate (PEM)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Private Key (PEM)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={uploadMutation.isPending}>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { checkHealth } from '../api/health'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { hosts } = useProxyHosts()
|
||||
const { servers } = useRemoteServers()
|
||||
const [health, setHealth] = useState<{ status: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
const result = await checkHealth()
|
||||
setHealth(result)
|
||||
} catch {
|
||||
setHealth({ status: 'error' })
|
||||
}
|
||||
}
|
||||
fetchHealth()
|
||||
}, [])
|
||||
|
||||
const enabledHosts = hosts.filter(h => h.enabled).length
|
||||
const enabledServers = servers.filter(s => s.enabled).length
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Link to="/proxy-hosts" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<div className="text-sm text-gray-400 mb-2">Proxy Hosts</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">{hosts.length}</div>
|
||||
<div className="text-xs text-gray-500">{enabledHosts} enabled</div>
|
||||
</Link>
|
||||
|
||||
<Link to="/remote-servers" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
|
||||
<div className="text-sm text-gray-400 mb-2">Remote Servers</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">{servers.length}</div>
|
||||
<div className="text-xs text-gray-500">{enabledServers} enabled</div>
|
||||
</Link>
|
||||
|
||||
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
|
||||
<div className="text-3xl font-bold text-white mb-1">0</div>
|
||||
<div className="text-xs text-gray-500">Coming soon</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-sm text-gray-400 mb-2">System Status</div>
|
||||
<div className={`text-lg font-bold ${health?.status === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{health?.status === 'ok' ? 'Healthy' : health ? 'Error' : 'Checking...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/proxy-hosts"
|
||||
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🌐</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">Add Proxy Host</div>
|
||||
<div className="text-xs text-gray-400">Create a new reverse proxy</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/remote-servers"
|
||||
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🖥️</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">Add Remote Server</div>
|
||||
<div className="text-xs text-gray-400">Register a backend server</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/import"
|
||||
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">📥</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">Import Caddyfile</div>
|
||||
<div className="text-xs text-gray-400">Bulk import from existing config</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { Trash2, Plus, Globe, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function Domains() {
|
||||
const { domains, isLoading, isFetching, error, createDomain, deleteDomain } = useDomains()
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createDomain(newDomain)
|
||||
setNewDomain('')
|
||||
} catch (err) {
|
||||
alert('Failed to create domain')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this domain?')) {
|
||||
try {
|
||||
await deleteDomain(uuid)
|
||||
} catch (err) {
|
||||
alert('Failed to delete domain')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-8 text-white">Loading...</div>
|
||||
if (error) return <div className="p-8 text-red-400">Error loading domains</div>
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Domains</h1>
|
||||
{isFetching && !isLoading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Add New Domain Card */}
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4 flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Add Domain
|
||||
</h3>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Domain Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newDomain.trim()}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Domain'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
{domains.map((domain) => (
|
||||
<div key={domain.uuid} className="bg-dark-card border border-gray-800 rounded-lg p-6 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-900/30 rounded-lg text-blue-400">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{domain.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Added {new Date(domain.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.uuid)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Delete Domain"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
}
|
||||
|
||||
const fetchHealth = async (): Promise<HealthResponse> => {
|
||||
const { data } = await client.get<HealthResponse>('/health');
|
||||
return data;
|
||||
};
|
||||
|
||||
const HealthStatus = () => {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['health'], queryFn: fetchHealth });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>System Status</h2>
|
||||
{isLoading && <p>Checking health…</p>}
|
||||
{isError && <p className="error">Unable to reach backend</p>}
|
||||
{data && (
|
||||
<ul>
|
||||
<li>Service: {data.service}</li>
|
||||
<li>Status: {data.status}</li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthStatus;
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useImport } from '../hooks/useImport'
|
||||
import ImportBanner from '../components/ImportBanner'
|
||||
import ImportReviewTable from '../components/ImportReviewTable'
|
||||
|
||||
export default function ImportCaddy() {
|
||||
const { session, preview, loading, error, upload, commit, cancel } = useImport()
|
||||
const [content, setContent] = useState('')
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!content.trim()) {
|
||||
alert('Please enter Caddyfile content')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await upload(content)
|
||||
setShowReview(true)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const text = await file.text()
|
||||
setContent(text)
|
||||
}
|
||||
|
||||
const handleCommit = async (resolutions: Record<string, string>) => {
|
||||
try {
|
||||
await commit(resolutions)
|
||||
setContent('')
|
||||
setShowReview(false)
|
||||
alert('Import completed successfully!')
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (confirm('Are you sure you want to cancel this import?')) {
|
||||
try {
|
||||
await cancel()
|
||||
setShowReview(false)
|
||||
} catch {
|
||||
// Error is already set by hook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Import Caddyfile</h1>
|
||||
|
||||
{session && (
|
||||
<ImportBanner
|
||||
session={session}
|
||||
onReview={() => setShowReview(true)}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning if preview is empty but session exists (e.g. mounted file was empty or invalid) */}
|
||||
{session && preview && preview.preview && preview.preview.hosts.length === 0 && (
|
||||
<div className="bg-yellow-900/20 border border-yellow-500 text-yellow-400 px-4 py-3 rounded mb-6">
|
||||
<p className="font-bold">No domains found in Caddyfile</p>
|
||||
<p className="text-sm mt-1">
|
||||
The imported file appears to be empty or contains no valid reverse_proxy directives.
|
||||
Please check the file content below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Upload or Paste Caddyfile</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Import an existing Caddyfile to automatically create proxy host configurations.
|
||||
The system will detect conflicts and allow you to review changes before committing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Upload Caddyfile
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".caddyfile,.txt,text/plain"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Or Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
<span className="text-gray-500 text-sm">or paste content</span>
|
||||
<div className="flex-1 border-t border-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* Text Area */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Caddyfile Content
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={loading || !content.trim()}
|
||||
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Processing...' : 'Parse and Review'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showReview && preview && preview.preview && (
|
||||
<ImportReviewTable
|
||||
hosts={preview.preview.hosts}
|
||||
conflicts={preview.preview.conflicts}
|
||||
errors={preview.preview.errors}
|
||||
caddyfileContent={preview.caddyfile_content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../utils/toast'
|
||||
import client from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { getSetupStatus } from '../api/setup'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showResetInfo, setShowResetInfo] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const { data: setupStatus, isLoading: isCheckingSetup } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (setupStatus?.setupRequired) {
|
||||
navigate('/setup')
|
||||
}
|
||||
}, [setupStatus, navigate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await client.post('/auth/login', { email, password })
|
||||
await login()
|
||||
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] })
|
||||
toast.success('Logged in successfully')
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCheckingSetup) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<div className="text-white">Checking setup status...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" title="Login">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetInfo(!showResetInfo)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResetInfo && (
|
||||
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 text-sm text-blue-200">
|
||||
<p className="mb-2 font-medium">To reset your password:</p>
|
||||
<p className="mb-2">Run this command on your server:</p>
|
||||
<code className="block bg-black/50 p-2 rounded font-mono text-xs break-all select-all">
|
||||
docker exec -it caddy-proxy-manager /app/backend reset-password <email> <new-password>
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getLogs, getLogContent, downloadLog, LogFilter } from '../api/logs';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Loader2, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { LogTable } from '../components/LogTable';
|
||||
import { LogFilters } from '../components/LogFilters';
|
||||
import { Button } from '../components/ui/Button';
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||
|
||||
// Filter State
|
||||
const [search, setSearch] = useState('');
|
||||
const [host, setHost] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const { data: logs, isLoading: isLoadingLogs } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: getLogs,
|
||||
});
|
||||
|
||||
// Select first log by default if none selected
|
||||
React.useEffect(() => {
|
||||
if (!selectedLog && logs && logs.length > 0) {
|
||||
setSelectedLog(logs[0].name);
|
||||
}
|
||||
}, [logs, selectedLog]);
|
||||
|
||||
const filter: LogFilter = {
|
||||
search,
|
||||
host,
|
||||
status,
|
||||
level,
|
||||
limit,
|
||||
offset: page * limit,
|
||||
sort
|
||||
};
|
||||
|
||||
const { data: logData, isLoading: isLoadingContent, refetch: refetchContent } = useQuery({
|
||||
queryKey: ['logContent', selectedLog, search, host, status, level, page, sort],
|
||||
queryFn: () => selectedLog ? getLogContent(selectedLog, filter) : Promise.resolve(null),
|
||||
enabled: !!selectedLog,
|
||||
});
|
||||
|
||||
const handleDownload = () => {
|
||||
if (selectedLog) {
|
||||
downloadLog(selectedLog);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = logData ? Math.ceil(logData.total / limit) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Access Logs</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Log File List */}
|
||||
<div className="md:col-span-1 space-y-4">
|
||||
<Card className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Log Files</h2>
|
||||
{isLoadingLogs ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs?.map((log) => (
|
||||
<button
|
||||
key={log.name}
|
||||
onClick={() => { setSelectedLog(log.name); setPage(0); }}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center ${
|
||||
selectedLog === log.name
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
<div className="flex-1 truncate">
|
||||
<div className="font-medium">{log.name}</div>
|
||||
<div className="text-xs text-gray-500">{(log.size / 1024 / 1024).toFixed(2)} MB</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4">No log files found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div className="md:col-span-3 space-y-4">
|
||||
{selectedLog ? (
|
||||
<>
|
||||
<LogFilters
|
||||
search={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(0); }}
|
||||
host={host}
|
||||
onHostChange={(v) => { setHost(v); setPage(0); }}
|
||||
status={status}
|
||||
onStatusChange={(v) => { setStatus(v); setPage(0); }}
|
||||
level={level}
|
||||
onLevelChange={(v) => { setLevel(v); setPage(0); }}
|
||||
sort={sort}
|
||||
onSortChange={(v) => { setSort(v); setPage(0); }}
|
||||
onRefresh={refetchContent}
|
||||
onDownload={handleDownload}
|
||||
isLoading={isLoadingContent}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<LogTable
|
||||
logs={logData?.logs || []}
|
||||
isLoading={isLoadingContent}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{logData && logData.total > 0 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {logData.offset + 1} to {Math.min(logData.offset + limit, logData.total)} of {logData.total} entries
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Page</span>
|
||||
<select
|
||||
value={page}
|
||||
onChange={(e) => setPage(Number(e.target.value))}
|
||||
className="block w-20 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white py-1"
|
||||
disabled={isLoadingContent}
|
||||
>
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">of {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0 || isLoadingContent}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages - 1 || isLoadingContent}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 flex flex-col items-center justify-center text-gray-500 h-64">
|
||||
<FileText className="w-12 h-12 mb-4 opacity-20" />
|
||||
<p>Select a log file to view contents</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -1,309 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, NotificationProvider } from '../api/notifications';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
const ProviderForm: React.FC<{
|
||||
initialData?: Partial<NotificationProvider>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}> = ({ initialData, onClose, onSubmit }) => {
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
|
||||
defaultValues: initialData || {
|
||||
type: 'discord',
|
||||
enabled: true,
|
||||
config: '',
|
||||
notify_proxy_hosts: true,
|
||||
notify_remote_servers: true,
|
||||
notify_domains: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true
|
||||
}
|
||||
});
|
||||
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => {
|
||||
setTestStatus('success');
|
||||
setTimeout(() => setTestStatus('idle'), 3000);
|
||||
},
|
||||
onError: () => {
|
||||
setTestStatus('error');
|
||||
setTimeout(() => setTestStatus('idle'), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTest = () => {
|
||||
const formData = watch();
|
||||
testMutation.mutate(formData as any);
|
||||
};
|
||||
|
||||
const type = watch('type');
|
||||
|
||||
const setTemplate = (template: string) => {
|
||||
setValue('config', template);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input
|
||||
{...register('name', { required: 'Name is required' })}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
/>
|
||||
{errors.name && <span className="text-red-500 text-xs">{errors.name.message as string}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Type</label>
|
||||
<select
|
||||
{...register('type')}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="gotify">Gotify</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="generic">Generic Webhook (Shoutrrr)</option>
|
||||
<option value="webhook">Custom Webhook (JSON)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">URL / Webhook</label>
|
||||
<input
|
||||
{...register('url', { required: 'URL is required' })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
|
||||
/>
|
||||
{type !== 'webhook' && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
For Shoutrrr format, see <a href="https://containrrr.dev/shoutrrr/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">documentation</a>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'webhook' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Payload Template</label>
|
||||
<div className="flex gap-2 mb-2 mt-1">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setTemplate('{"content": "{{.Title}}: {{.Message}}"}')}>
|
||||
Simple Template
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setTemplate(`{
|
||||
"embeds": [{
|
||||
"title": "{{.Title}}",
|
||||
"description": "{{.Message}}",
|
||||
"color": 15158332,
|
||||
"fields": [
|
||||
{ "name": "Monitor", "value": "{{.Name}}", "inline": true },
|
||||
{ "name": "Status", "value": "{{.Status}}", "inline": true },
|
||||
{ "name": "Latency", "value": "{{.Latency}}ms", "inline": true }
|
||||
]
|
||||
}]
|
||||
}`)}>
|
||||
Detailed Template (Discord)
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
{...register('config')}
|
||||
rows={8}
|
||||
className="mt-1 block w-full font-mono text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
placeholder='{"text": "{{.Message}}"}'
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Available variables: .Title, .Message, .Status, .Name, .Latency, .Time
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Notification Events</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_proxy_hosts')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Proxy Hosts</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_remote_servers')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Remote Servers</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_domains')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Domains</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_certs')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Certificates</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input type="checkbox" {...register('notify_uptime')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
|
||||
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Uptime</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('enabled')}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTest}
|
||||
disabled={testMutation.isPending}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> :
|
||||
testStatus === 'success' ? <Check className="w-4 h-4 text-green-500 mx-auto" /> :
|
||||
testStatus === 'error' ? <X className="w-4 h-4 text-red-500 mx-auto" /> :
|
||||
"Test"}
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Notifications: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const { data: providers, isLoading } = useQuery({
|
||||
queryKey: ['notificationProviders'],
|
||||
queryFn: getProviders,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
setIsAdding(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => updateProvider(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testProvider,
|
||||
onSuccess: () => alert('Test notification sent!'),
|
||||
onError: (err: any) => alert(`Failed to send test: ${err.response?.data?.error || err.message}`),
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Bell className="w-6 h-6" />
|
||||
Notification Providers
|
||||
</h1>
|
||||
<Button onClick={() => setIsAdding(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<Card className="p-6 mb-6 border-blue-500 border-2">
|
||||
<h3 className="text-lg font-medium mb-4">Add New Provider</h3>
|
||||
<ProviderForm
|
||||
onClose={() => setIsAdding(false)}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{providers?.map((provider) => (
|
||||
<Card key={provider.id} className="p-4">
|
||||
{editingId === provider.id ? (
|
||||
<ProviderForm
|
||||
initialData={provider}
|
||||
onClose={() => setEditingId(null)}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2 rounded-full ${provider.enabled ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
|
||||
<Bell className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">{provider.name}</h3>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<span className="uppercase text-xs font-bold bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{provider.type}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-50">{provider.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => testMutation.mutate(provider)}
|
||||
isLoading={testMutation.isPending}
|
||||
title="Send Test Notification"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditingId(provider.id)}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure?')) deleteMutation.mutate(provider.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{providers?.length === 0 && !isAdding && (
|
||||
<div className="text-center py-12 text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
No notification providers configured.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { getSettings } from '../api/settings'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import ProxyHostForm from '../components/ProxyHostForm'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
|
||||
export default function ProxyHosts() {
|
||||
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost } = useProxyHosts()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
const linkBehavior = settings?.['ui.domain_link_behavior'] || 'new_tab'
|
||||
|
||||
const handleDomainClick = (e: React.MouseEvent, url: string) => {
|
||||
if (linkBehavior === 'new_window') {
|
||||
e.preventDefault()
|
||||
window.open(url, '_blank', 'noopener,noreferrer,width=1024,height=768')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingHost(undefined)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (host: ProxyHost) => {
|
||||
setEditingHost(host)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: Partial<ProxyHost>) => {
|
||||
if (editingHost) {
|
||||
await updateHost(editingHost.uuid, data)
|
||||
} else {
|
||||
await createHost(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingHost(undefined)
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this proxy host?')) {
|
||||
try {
|
||||
await deleteHost(uuid)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
|
||||
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add Proxy Host
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
No proxy hosts configured yet. Click "Add Proxy Host" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Domain
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Forward To
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
SSL
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{hosts.map((host) => (
|
||||
<tr key={host.uuid} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">
|
||||
{host.domain_names.split(',').map((domain, i) => {
|
||||
const url = `${host.ssl_forced ? 'https' : 'http'}://${domain.trim()}`
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<a
|
||||
href={url}
|
||||
target={linkBehavior === 'same_tab' ? '_self' : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => handleDomainClick(e, url)}
|
||||
className="hover:text-blue-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{domain.trim()}
|
||||
<ExternalLink size={12} className="opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300">
|
||||
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
{host.ssl_forced && (
|
||||
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
|
||||
SSL
|
||||
</span>
|
||||
)}
|
||||
{host.websocket_support && (
|
||||
<span className="px-2 py-1 text-xs bg-blue-900/30 text-blue-400 rounded">
|
||||
WS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={host.enabled}
|
||||
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
|
||||
/>
|
||||
<span className={`text-sm ${host.enabled ? 'text-green-400' : 'text-gray-400'}`}>
|
||||
{host.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(host)}
|
||||
className="text-blue-400 hover:text-blue-300 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(host.uuid)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ProxyHostForm
|
||||
host={editingHost}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingHost(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import type { RemoteServer } from '../api/remoteServers'
|
||||
import RemoteServerForm from '../components/RemoteServerForm'
|
||||
|
||||
export default function RemoteServers() {
|
||||
const { servers, loading, isFetching, error, createServer, updateServer, deleteServer } = useRemoteServers()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingServer, setEditingServer] = useState<RemoteServer | undefined>()
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingServer(undefined)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (server: RemoteServer) => {
|
||||
setEditingServer(server)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: Partial<RemoteServer>) => {
|
||||
if (editingServer) {
|
||||
await updateServer(editingServer.uuid, data)
|
||||
} else {
|
||||
await createServer(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingServer(undefined)
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this remote server?')) {
|
||||
try {
|
||||
await deleteServer(uuid)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">Remote Servers</h1>
|
||||
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-active text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-active text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
|
||||
<div className="text-center text-gray-400 py-12">
|
||||
No remote servers configured. Add servers to quickly select backends when creating proxy hosts.
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.uuid}
|
||||
className="bg-dark-card rounded-lg border border-gray-800 p-6 hover:border-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{server.name}</h3>
|
||||
<span className="inline-block px-2 py-1 text-xs bg-gray-800 text-gray-400 rounded">
|
||||
{server.provider}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
server.enabled
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{server.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Host:</span>
|
||||
<span className="text-white font-mono">{server.host}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">Port:</span>
|
||||
<span className="text-white font-mono">{server.port}</span>
|
||||
</div>
|
||||
{server.username && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">User:</span>
|
||||
<span className="text-white font-mono">{server.username}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4 border-t border-gray-800">
|
||||
<button
|
||||
onClick={() => handleEdit(server)}
|
||||
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(server.uuid)}
|
||||
className="flex-1 px-3 py-2 bg-red-900/20 hover:bg-red-900/30 text-red-400 text-sm rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Host
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Port
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{servers.map((server) => (
|
||||
<tr key={server.uuid} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">{server.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs bg-gray-800 text-gray-400 rounded">
|
||||
{server.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300 font-mono">{server.host}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-300 font-mono">{server.port}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
server.enabled
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{server.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(server)}
|
||||
className="text-blue-400 hover:text-blue-300 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(server.uuid)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<RemoteServerForm
|
||||
server={editingServer}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false)
|
||||
setEditingServer(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
export default function Settings() {
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Settings</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Manage system and account settings</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
to="/settings/system"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/settings/system')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
System
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/account"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/settings/account')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
|
||||
import client from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter';
|
||||
import { isValidEmail } from '../utils/validation';
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const [formData, setFormData] = useState<SetupRequest>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailValid, setEmailValid] = useState<boolean | null>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.email) {
|
||||
setEmailValid(isValidEmail(formData.email));
|
||||
} else {
|
||||
setEmailValid(null);
|
||||
}
|
||||
}, [formData.email]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for setup status to load
|
||||
if (statusLoading) return;
|
||||
|
||||
// If setup is required, stay on this page (ignore stale auth)
|
||||
if (status?.setupRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If setup is NOT required, redirect based on auth
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [status, statusLoading, isAuthenticated, navigate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: SetupRequest) => {
|
||||
// 1. Perform Setup
|
||||
await performSetup(data);
|
||||
// 2. Auto Login
|
||||
await client.post('/auth/login', { email: data.email, password: data.password });
|
||||
// 3. Update Auth Context
|
||||
await login();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
|
||||
navigate('/');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || 'Setup failed');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.setupRequired) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Welcome to CPM+
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Create your administrator account to get started.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Admin User"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className={emailValid === false ? 'border-red-500 focus:ring-red-500' : emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
|
||||
/>
|
||||
{emailValid === false && (
|
||||
<p className="mt-1 text-xs text-red-500">Please enter a valid email address</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
<PasswordStrengthMeter password={formData.password} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={mutation.isPending}
|
||||
>
|
||||
Create Admin Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -1,256 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { toast } from '../utils/toast'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import client from '../api/client'
|
||||
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
|
||||
|
||||
interface HealthResponse {
|
||||
status: string
|
||||
service: string
|
||||
version: string
|
||||
git_commit: string
|
||||
build_time: string
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
current_version: string
|
||||
latest_version: string
|
||||
update_available: boolean
|
||||
release_url?: string
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [caddyAdminAPI, setCaddyAdminAPI] = useState('http://localhost:2019')
|
||||
const [sslProvider, setSslProvider] = useState('letsencrypt')
|
||||
const [domainLinkBehavior, setDomainLinkBehavior] = useState('new_tab')
|
||||
|
||||
// Fetch Settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
})
|
||||
|
||||
// Update local state when settings load
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
if (settings['caddy.admin_api']) setCaddyAdminAPI(settings['caddy.admin_api'])
|
||||
if (settings['caddy.ssl_provider']) setSslProvider(settings['caddy.ssl_provider'])
|
||||
if (settings['ui.domain_link_behavior']) setDomainLinkBehavior(settings['ui.domain_link_behavior'])
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
// Fetch Health/System Status
|
||||
const { data: health, isLoading: isLoadingHealth } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async (): Promise<HealthResponse> => {
|
||||
const response = await client.get<HealthResponse>('/health')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
// Check for Updates
|
||||
const {
|
||||
data: updateInfo,
|
||||
refetch: checkUpdates,
|
||||
isFetching: isCheckingUpdates,
|
||||
} = useQuery({
|
||||
queryKey: ['updates'],
|
||||
queryFn: async (): Promise<UpdateInfo> => {
|
||||
const response = await client.get<UpdateInfo>('/system/updates')
|
||||
return response.data
|
||||
},
|
||||
enabled: false, // Manual trigger
|
||||
})
|
||||
|
||||
const saveSettingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await updateSetting('caddy.admin_api', caddyAdminAPI, 'caddy', 'string')
|
||||
await updateSetting('caddy.ssl_provider', sslProvider, 'caddy', 'string')
|
||||
await updateSetting('ui.domain_link_behavior', domainLinkBehavior, 'ui', 'string')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success('System settings saved')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-8 h-8" />
|
||||
System Settings
|
||||
</h1>
|
||||
|
||||
{/* General Configuration */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">General Configuration</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Caddy Admin API Endpoint"
|
||||
type="text"
|
||||
value={caddyAdminAPI}
|
||||
onChange={(e) => setCaddyAdminAPI(e.target.value)}
|
||||
placeholder="http://localhost:2019"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 -mt-2">
|
||||
URL to the Caddy admin API (usually on port 2019)
|
||||
</p>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
SSL Provider
|
||||
</label>
|
||||
<select
|
||||
value={sslProvider}
|
||||
onChange={(e) => setSslProvider(e.target.value)}
|
||||
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 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="letsencrypt">Let's Encrypt (Default)</option>
|
||||
<option value="zerossl">ZeroSSL</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Choose the default Certificate Authority for SSL certificates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
Domain Link Behavior
|
||||
</label>
|
||||
<select
|
||||
value={domainLinkBehavior}
|
||||
onChange={(e) => setDomainLinkBehavior(e.target.value)}
|
||||
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 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="same_tab">Same Tab</option>
|
||||
<option value="new_tab">New Tab (Default)</option>
|
||||
<option value="new_window">New Window</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Control how domain links open in the Proxy Hosts list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
System Status
|
||||
</h2>
|
||||
{isLoadingHealth ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Service</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.service}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
|
||||
<p className="text-lg font-medium text-green-600 dark:text-green-400 capitalize">
|
||||
{health.status}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Version</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Build Time</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{health.build_time || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Git Commit</p>
|
||||
<p className="text-sm font-mono text-gray-900 dark:text-white">
|
||||
{health.git_commit || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-500">Unable to fetch system status</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Update Check */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Software Updates</h2>
|
||||
<div className="space-y-4">
|
||||
{updateInfo && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Current Version</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{updateInfo.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Latest Version</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{updateInfo.latest_version}
|
||||
</p>
|
||||
</div>
|
||||
{updateInfo.update_available && (
|
||||
<div className="md:col-span-2">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-blue-800 dark:text-blue-300 font-medium">
|
||||
A new version is available!
|
||||
</p>
|
||||
{updateInfo.release_url && (
|
||||
<a
|
||||
href={updateInfo.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
||||
>
|
||||
View Release Notes
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!updateInfo.update_available && (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-green-600 dark:text-green-400">
|
||||
✓ You are running the latest version
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => checkUpdates()}
|
||||
isLoading={isCheckingUpdates}
|
||||
variant="secondary"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Check for Updates
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
|
||||
export default function Tasks() {
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Tasks</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Manage system tasks and view logs</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
to="/tasks/backups"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/tasks/backups')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Backups
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/tasks/logs"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive('/tasks/logs')
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
Logs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getMonitors, getMonitorHistory } from '../api/uptime';
|
||||
import { Activity, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const MonitorCard: React.FC<{ monitor: any }> = ({ monitor }) => {
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ['uptimeHistory', monitor.id],
|
||||
queryFn: () => getMonitorHistory(monitor.id, 60),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const isUp = monitor.status === 'up';
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isUp ? 'border-l-green-500' : 'border-l-red-500'}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{monitor.name}</h3>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<a href={`http://${monitor.url}`} target="_blank" rel="noreferrer" className="hover:underline">
|
||||
{monitor.url}
|
||||
</a>
|
||||
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
|
||||
{monitor.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
isUp ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
|
||||
{monitor.status.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Latency</div>
|
||||
<div className="text-lg font-mono font-medium text-gray-900 dark:text-white">
|
||||
{monitor.latency}ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Check</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{monitor.last_check ? formatDistanceToNow(new Date(monitor.last_check), { addSuffix: true }) : 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
|
||||
<div className="flex gap-[2px] h-8 items-end" title="Last 60 checks (1 Hour)">
|
||||
{/* Fill with empty bars if not enough history to keep alignment right-aligned */}
|
||||
{Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-sm h-full opacity-50" />
|
||||
))}
|
||||
|
||||
{history?.slice().reverse().map((beat: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm transition-all duration-200 hover:scale-110 ${
|
||||
beat.status === 'up'
|
||||
? 'bg-green-400 dark:bg-green-500 hover:bg-green-300'
|
||||
: 'bg-red-400 dark:bg-red-500 hover:bg-red-300'
|
||||
}`}
|
||||
style={{ height: '100%' }}
|
||||
title={`${new Date(beat.created_at).toLocaleString()}
|
||||
Status: ${beat.status.toUpperCase()}
|
||||
Latency: ${beat.latency}ms
|
||||
Message: ${beat.message}`}
|
||||
/>
|
||||
))}
|
||||
{(!history || history.length === 0) && (
|
||||
<div className="absolute w-full text-center text-xs text-gray-400">No history available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Uptime: React.FC = () => {
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: getMonitors,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Loading monitors...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-6 h-6" />
|
||||
Uptime Monitoring
|
||||
</h1>
|
||||
<div className="text-sm text-gray-500">
|
||||
Auto-refreshing every 30s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{monitors?.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||
))}
|
||||
{monitors?.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-gray-500">
|
||||
No monitors found. Add a Proxy Host to start monitoring.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Uptime;
|
||||
@@ -1,146 +0,0 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import Setup from '../Setup';
|
||||
import * as setupApi from '../../api/setup';
|
||||
|
||||
// Mock AuthContext so useAuth works in tests
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
get: vi.fn().mockResolvedValue({ data: {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
performSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Setup Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders setup form when setup is required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Name')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Email Address')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Password')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render form when setup is not required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Welcome to CPM+')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form successfully', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Admin Account' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setupApi.performSetup).toHaveBeenCalledWith({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error on submission failure', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockRejectedValue({
|
||||
response: { data: { error: 'Setup failed' } }
|
||||
});
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Admin Account' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Setup failed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { ProxyHost } from '../hooks/useProxyHosts'
|
||||
import { RemoteServer } from '../hooks/useRemoteServers'
|
||||
|
||||
export const mockProxyHosts: ProxyHost[] = [
|
||||
{
|
||||
uuid: '123e4567-e89b-12d3-a456-426614174000',
|
||||
domain_names: 'app.local.dev',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 3000,
|
||||
ssl_forced: false,
|
||||
http2_support: true,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
locations: [],
|
||||
advanced_config: undefined,
|
||||
enabled: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
},
|
||||
{
|
||||
uuid: '223e4567-e89b-12d3-a456-426614174001',
|
||||
domain_names: 'api.local.dev',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.100',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
http2_support: true,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
locations: [],
|
||||
advanced_config: undefined,
|
||||
enabled: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockRemoteServers: RemoteServer[] = [
|
||||
{
|
||||
uuid: '323e4567-e89b-12d3-a456-426614174002',
|
||||
name: 'Local Docker Registry',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
username: undefined,
|
||||
enabled: true,
|
||||
reachable: false,
|
||||
last_check: undefined,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
},
|
||||
{
|
||||
uuid: '423e4567-e89b-12d3-a456-426614174003',
|
||||
name: 'Development API Server',
|
||||
provider: 'generic',
|
||||
host: '192.168.1.100',
|
||||
port: 8080,
|
||||
username: undefined,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
last_check: '2025-11-18T10:00:00Z',
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockImportPreview = {
|
||||
hosts: [
|
||||
{
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
websocket_support: false,
|
||||
},
|
||||
],
|
||||
conflicts: ['app.local.dev'],
|
||||
errors: [],
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
}),
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
export interface PasswordStrength {
|
||||
score: number; // 0-4
|
||||
label: string;
|
||||
color: string; // Tailwind color class prefix (e.g., 'red', 'yellow', 'green')
|
||||
feedback: string[];
|
||||
}
|
||||
|
||||
export function calculatePasswordStrength(password: string): PasswordStrength {
|
||||
let score = 0;
|
||||
const feedback: string[] = [];
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
score: 0,
|
||||
label: 'Empty',
|
||||
color: 'gray',
|
||||
feedback: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Length check
|
||||
if (password.length < 8) {
|
||||
feedback.push('Too short (min 8 chars)');
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (password.length >= 12) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Complexity checks
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
|
||||
if (varietyCount >= 3) {
|
||||
score += 1;
|
||||
}
|
||||
if (varietyCount === 4) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Penalties
|
||||
if (varietyCount < 2 && password.length >= 8) {
|
||||
feedback.push('Add more variety (uppercase, numbers, symbols)');
|
||||
}
|
||||
|
||||
// Cap score at 4
|
||||
score = Math.min(score, 4);
|
||||
|
||||
// Determine label and color
|
||||
let label = 'Very Weak';
|
||||
let color = 'red';
|
||||
|
||||
switch (score) {
|
||||
case 0:
|
||||
case 1:
|
||||
label = 'Weak';
|
||||
color = 'red';
|
||||
break;
|
||||
case 2:
|
||||
label = 'Fair';
|
||||
color = 'yellow';
|
||||
break;
|
||||
case 3:
|
||||
label = 'Good';
|
||||
color = 'green';
|
||||
break;
|
||||
case 4:
|
||||
label = 'Strong';
|
||||
color = 'green';
|
||||
break;
|
||||
}
|
||||
|
||||
return { score, label, color, feedback };
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
let toastId = 0
|
||||
export const toastCallbacks = new Set<(toast: Toast) => void>()
|
||||
|
||||
export const toast = {
|
||||
success: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'success' }))
|
||||
},
|
||||
error: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'error' }))
|
||||
},
|
||||
info: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'info' }))
|
||||
},
|
||||
warning: (message: string) => {
|
||||
const id = ++toastId
|
||||
toastCallbacks.forEach(callback => callback({ id, message, type: 'warning' }))
|
||||
},
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user