feat: add plugin/theme harness and admin dashboard
Plugin & Theme System: - Add plugin type definitions, permissions (30+), and validation constants - Add IndexedDB storage layer for plugin code, theme CSS, and previews - Add theme CSS sanitization, injection, and safety validation - Add HookBus event system with 130+ hooks across 20 domains - Add plugin ZIP extraction and manifest validation with JS security checks - Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating - Add plugin loader with blob URL dynamic import and auto-disable circuit breaker - Add 3 built-in themes (Nord, Catppuccin, Solarized) - Add Zustand plugin store with install/uninstall/enable/disable lifecycle - Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components - Add plugins and themes settings UI panels - Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu - Extend theme store with custom theme installation and activation Admin Dashboard: - Add admin authentication with scrypt password hashing and AES-256-GCM sessions - Add rate-limited login (5 attempts/15min per IP) - Add config manager with admin override > env var > default priority - Add settings policy system with feature gates and per-setting restrictions - Add audit logging with rotation - Add admin API routes (login, logout, config, policy, audit, password change) - Add admin UI pages (login, dashboard, config, policy, audit) - Add policy store for client-side feature gate enforcement - Wire admin password initialization into server instrumentation Tests: - Add 139 tests across 10 test files covering all plugin/theme modules
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export default function AdminAuthPage() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): unknown {
|
||||
if (key in edits) return edits[key];
|
||||
return config[key]?.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Authentication settings saved.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth */}
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
</Section>
|
||||
|
||||
{/* SSO */}
|
||||
<Section title="Single Sign-On">
|
||||
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
|
||||
{/* Session & Security */}
|
||||
<Section title="Session & Security">
|
||||
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
const IMAGE_FIELDS = [
|
||||
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
|
||||
export default function AdminBrandingPage() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key] as string;
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
|
||||
const res = await fetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||
// Remove any pending URL edit for this slot since upload sets it
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
// Update config to reflect the uploaded URL
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
}));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
setUploading(null);
|
||||
}
|
||||
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview */}
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSuccess(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => router.push('/admin'), 2000);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || 'Failed to change password.');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Palette,
|
||||
Shield,
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
{ href: '/admin/branding', label: 'Branding', icon: Palette },
|
||||
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
|
||||
{ href: '/admin/policy', label: 'Policy', icon: Scale },
|
||||
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/auth');
|
||||
const data = await res.json();
|
||||
if (!data.enabled) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
if (!data.authenticated) {
|
||||
router.replace('/admin/login');
|
||||
return;
|
||||
}
|
||||
setAuthenticated(true);
|
||||
} catch {
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
// Don't gate the login page
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 border-r border-border bg-secondary/30 flex flex-col">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-2 px-2 space-y-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t border-border space-y-0.5">
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
Change Password
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors w-full text-left"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Login failed');
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/admin');
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||
) : (
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const limit = 50;
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (actionFilter) params.set('action', actionFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/audit?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total || 0);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, actionFilter]);
|
||||
|
||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<option value="admin.login">Login</option>
|
||||
<option value="admin.logout">Logout</option>
|
||||
<option value="admin.login_failed">Login Failed</option>
|
||||
<option value="admin.login_blocked">Login Blocked</option>
|
||||
<option value="admin.change-password">Password Change</option>
|
||||
<option value="config.update">Config Update</option>
|
||||
<option value="config.revert">Config Revert</option>
|
||||
<option value="policy.update">Policy Update</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||
</tr>
|
||||
) : entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<tr key={i} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||
{formatDetail(entry.detail)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||
if (detail.changes && Array.isArray(detail.changes)) {
|
||||
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
||||
}
|
||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||
return JSON.stringify(detail).slice(0, 100);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Server, AlertTriangle, Clock, Globe } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
|
||||
interface AdminStatus {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
lastLogin: string | null;
|
||||
passwordChangedAt: string | null;
|
||||
}
|
||||
|
||||
interface ConfigData {
|
||||
appName?: string;
|
||||
jmapServerUrl?: string;
|
||||
settingsSyncEnabled?: boolean;
|
||||
stalwartFeaturesEnabled?: boolean;
|
||||
oauthEnabled?: boolean;
|
||||
devMode?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes] = await Promise.all([
|
||||
fetch('/api/admin/auth'),
|
||||
fetch('/api/admin/audit?limit=10'),
|
||||
fetch('/api/config'),
|
||||
fetch('/api/admin/config'),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
if (auditRes.ok) {
|
||||
const data = await auditRes.json();
|
||||
setRecentActivity(data.entries || []);
|
||||
}
|
||||
if (configRes.ok) setConfig(await configRes.json());
|
||||
|
||||
// Build warnings
|
||||
const w: string[] = [];
|
||||
if (adminConfigRes.ok) {
|
||||
const sources = await adminConfigRes.json();
|
||||
setConfigSources(sources);
|
||||
const sessionSecret = sources?.sessionSecret;
|
||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||
}
|
||||
}
|
||||
setWarnings(w);
|
||||
}
|
||||
|
||||
const jmapUrl = config?.jmapServerUrl || '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Server overview and recent activity</p>
|
||||
</div>
|
||||
|
||||
{/* Status cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatusCard
|
||||
icon={<Server className="w-4 h-4" />}
|
||||
label="Application"
|
||||
value={config?.appName || '—'}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Globe className="w-4 h-4" />}
|
||||
label="JMAP Server"
|
||||
value={jmapUrl ? new URL(jmapUrl).hostname : '—'}
|
||||
detail={jmapUrl}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
label="Last Login"
|
||||
value={status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feature status row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<FeaturePill label="Admin" active={!!status?.enabled} />
|
||||
<FeaturePill label="Settings Sync" active={!!config?.settingsSyncEnabled} />
|
||||
<FeaturePill label="OAuth" active={!!config?.oauthEnabled} />
|
||||
<FeaturePill label="Stalwart" active={config?.stalwartFeaturesEnabled !== false} />
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status && !status.lastLogin && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">First login detected</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-0.5">
|
||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-foreground mb-3">Recent Activity</h2>
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((entry, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="text-sm text-foreground">
|
||||
{formatDetail(entry.detail)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{entry.ip}</span>
|
||||
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 bg-secondary/20">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||
{icon}
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-foreground truncate">{value}</div>
|
||||
{detail && <p className="text-xs text-muted-foreground mt-1 truncate">{detail}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturePill({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border border-border rounded-md px-3 py-2">
|
||||
<span className={`w-2 h-2 rounded-full ${active ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
|
||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||
<span className={`text-xs ml-auto ${active ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
|
||||
{active ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '';
|
||||
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
|
||||
return JSON.stringify(detail).slice(0, 80);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, Lock } from 'lucide-react';
|
||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
const FEATURE_GATE_LABELS: Record<keyof FeatureGates, { label: string; description: string }> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
userThemesEnabled: { label: 'User Themes', description: 'Allow user-uploaded theme files' },
|
||||
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
|
||||
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
|
||||
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
|
||||
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
|
||||
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
|
||||
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
|
||||
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
|
||||
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
|
||||
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
|
||||
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
|
||||
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
|
||||
];
|
||||
|
||||
export default function AdminPolicyPage() {
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => { fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) setPolicy(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function toggleFeature(key: keyof FeatureGates) {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, [key]: !prev.features[key] },
|
||||
}));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleLocked(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
const newRestrictions = { ...prev.restrictions };
|
||||
if (existing.locked) {
|
||||
delete newRestrictions[settingKey];
|
||||
} else {
|
||||
newRestrictions[settingKey] = { ...existing, locked: true };
|
||||
}
|
||||
return { ...prev, restrictions: newRestrictions };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleHidden(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
const newRestrictions = { ...prev.restrictions };
|
||||
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
|
||||
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
|
||||
delete newRestrictions[settingKey];
|
||||
}
|
||||
return { ...prev, restrictions: newRestrictions };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
|
||||
setDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save policy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Gates */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]).map(key => {
|
||||
const { label, description } = FEATURE_GATE_LABELS[key];
|
||||
const enabled = policy.features[key];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleFeature(key)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setting Restrictions */}
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||
const restriction = policy.restrictions[setting.key] || {};
|
||||
return (
|
||||
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{setting.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||
className="rounded border-input" />
|
||||
<Lock className="w-3 h-3" /> Lock
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||
className="rounded border-input" />
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
if (res.ok) {
|
||||
setConfig(await res.json());
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): unknown {
|
||||
if (key in edits) return edits[key];
|
||||
return config[key]?.value;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
setMessage({ type: 'success', text: `${key} reverted to default` });
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General */}
|
||||
<SettingsSection title="General">
|
||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
{/* Logging */}
|
||||
<SettingsSection title="Logging">
|
||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
{/* Settings Sync */}
|
||||
<SettingsSection title="Settings Sync">
|
||||
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
|
||||
label: string; configKey: string; value: string; source?: string;
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
|
||||
label: string; description?: string; configKey: string; value: boolean; source?: string;
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
|
||||
label: string; configKey: string; value: string; source?: string; options: string[];
|
||||
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user