feat: bundle plugin src/ on demand via esbuild
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export function AuthTab() {
|
||||
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 apiFetch('/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 apiFetch('/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 apiFetch('/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 [setupRunning, setSetupRunning] = useState(false);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupOrigin, setSetupOrigin] = useState('');
|
||||
const [setupIssuer, setSetupIssuer] = useState('');
|
||||
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
|
||||
|
||||
function openSetupDialog() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const origin = window.location.origin;
|
||||
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
|
||||
setSetupOrigin(origin);
|
||||
setSetupIssuer(jmapUrl || origin);
|
||||
setSetupOauthOnly(currentValue('oauthOnly') === true);
|
||||
setSetupOpen(true);
|
||||
}
|
||||
|
||||
async function handleAutoSetup() {
|
||||
setSetupRunning(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/oauth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
origin: setupOrigin.trim().replace(/\/+$/, ''),
|
||||
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
|
||||
oauthOnly: setupOauthOnly,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
|
||||
});
|
||||
setEdits({});
|
||||
setSetupOpen(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
|
||||
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
|
||||
} finally {
|
||||
setSetupRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
|
||||
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
|
||||
|
||||
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 flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
|
||||
Requires your Stalwart account to have admin permissions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openSetupDialog}
|
||||
disabled={setupRunning}
|
||||
className="shrink-0 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"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-setup-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||
Webmail origin
|
||||
</label>
|
||||
<input
|
||||
id="setup-origin"
|
||||
type="url"
|
||||
value={setupOrigin}
|
||||
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://webmail.example.com"
|
||||
className="w-full h-9 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"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||
</p>
|
||||
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||
Stalwart issuer URL
|
||||
</label>
|
||||
<input
|
||||
id="setup-issuer"
|
||||
type="url"
|
||||
value={setupIssuer}
|
||||
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://mail.example.com"
|
||||
className="w-full h-9 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"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||
</p>
|
||||
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupOauthOnly}
|
||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-input"
|
||||
disabled={setupRunning}
|
||||
/>
|
||||
Also enable “OAuth only” (hide password login)
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||
<button
|
||||
onClick={() => setSetupOpen(false)}
|
||||
disabled={setupRunning}
|
||||
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoSetup}
|
||||
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||
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"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 w-full sm:w-auto">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
|
||||
className="h-8 w-full sm: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="shrink-0 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 shrink-0">
|
||||
<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/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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,297 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
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 function BrandingTab() {
|
||||
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 apiFetch('/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 apiFetch('/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 apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
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 apiFetch('/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 apiFetch('/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 flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{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 w-full sm:w-auto">
|
||||
<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-full sm:w-64 min-w-0 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>
|
||||
{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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{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 w-full sm:w-auto">
|
||||
<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-full sm:w-72 min-w-0 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,224 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
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 function DashboardTab() {
|
||||
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[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
const [policyRuleCount, setPolicyRuleCount] = useState(0);
|
||||
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
|
||||
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
|
||||
apiFetch('/api/admin/auth'),
|
||||
apiFetch('/api/admin/audit?limit=10'),
|
||||
apiFetch('/api/config'),
|
||||
apiFetch('/api/admin/config'),
|
||||
apiFetch('/api/admin/plugins').catch(() => null),
|
||||
apiFetch('/api/admin/themes').catch(() => null),
|
||||
apiFetch('/api/admin/policy').catch(() => null),
|
||||
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
if (auditRes.ok) {
|
||||
const data = await auditRes.json();
|
||||
setRecentActivity(data.entries || []);
|
||||
}
|
||||
let configData: ConfigData | null = null;
|
||||
if (configRes.ok) {
|
||||
configData = await configRes.json();
|
||||
setConfig(configData);
|
||||
}
|
||||
|
||||
if (pluginRes?.ok) {
|
||||
const plugins = await pluginRes.json();
|
||||
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
|
||||
}
|
||||
if (themeRes?.ok) {
|
||||
const themes = await themeRes.json();
|
||||
setThemeCount(Array.isArray(themes) ? themes.length : 0);
|
||||
}
|
||||
if (policyRes?.ok) {
|
||||
const policy = await policyRes.json();
|
||||
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
|
||||
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
||||
}
|
||||
if (telemetryRes?.ok) {
|
||||
const telemetry = await telemetryRes.json();
|
||||
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
|
||||
setAccountCounts(telemetry.accountCounts);
|
||||
}
|
||||
}
|
||||
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
const jmapRes = await apiFetch('/api/config');
|
||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||
} catch {
|
||||
setJmapHealth('error');
|
||||
}
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
const adminPassword = sources?.adminPassword;
|
||||
if (adminPassword?.value && adminPassword.source === 'env') {
|
||||
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
|
||||
}
|
||||
}
|
||||
setWarnings(w);
|
||||
}
|
||||
|
||||
const jmapUrl = config?.jmapServerUrl || '-';
|
||||
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-warning">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status && !status.lastLogin && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-warning">First login detected</p>
|
||||
<p className="text-sm text-warning/80 mt-0.5">
|
||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Server" description="Application and connection details">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Connection">
|
||||
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${
|
||||
jmapHealth === 'ok' ? 'text-green-600 dark:text-green-400' : jmapHealth === 'error' ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground'
|
||||
}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
jmapHealth === 'ok' ? 'bg-green-500' : jmapHealth === 'error' ? 'bg-red-500' : 'bg-muted-foreground/40'
|
||||
}`} />
|
||||
{jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last Login">
|
||||
<span className="text-sm text-foreground">
|
||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Features" description="Enabled integrations and modules">
|
||||
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
||||
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
||||
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
||||
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||
<SettingItem label="Plugins">
|
||||
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Themes">
|
||||
<span className="text-sm text-foreground">{themeCount}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Policy Rules">
|
||||
<span className="text-sm text-foreground">{policyRuleCount}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="py-4 text-sm text-muted-foreground">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((entry, i) => (
|
||||
<SettingItem
|
||||
key={i}
|
||||
label={entry.action}
|
||||
description={formatDetail(entry.detail) || undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{entry.ip}</span>
|
||||
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
))
|
||||
)}
|
||||
</SettingsSection>
|
||||
</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,453 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
forceEnabled?: boolean;
|
||||
permissions: string[];
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function PluginsTab() {
|
||||
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function togglePluginsEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function togglePluginsUploadEnabled() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleRequirePluginApproval() {
|
||||
setPolicy(prev => ({
|
||||
...prev,
|
||||
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||
}));
|
||||
setPolicyDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSavePolicy() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
|
||||
setPolicyDirty(false);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save policy' });
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlugins() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
if (res.ok) setPlugins(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||
await fetchPlugins();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||
setMessage(null);
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||
setPolicy(prev => {
|
||||
const current = prev.forceEnabledPlugins || [];
|
||||
return {
|
||||
...prev,
|
||||
forceEnabledPlugins: forceEnabled
|
||||
? [...current.filter(pid => pid !== id), id]
|
||||
: current.filter(pid => pid !== id),
|
||||
};
|
||||
});
|
||||
setPolicyDirty(true);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async function forceEnableAll() {
|
||||
setMessage(null);
|
||||
const disabled = plugins.filter(p => !p.enabled);
|
||||
if (disabled.length === 0) {
|
||||
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||
return;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of disabled) {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
}
|
||||
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||
if (failed === 0) {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||
} else {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||
}
|
||||
}
|
||||
|
||||
async function forceDisableAll() {
|
||||
setMessage(null);
|
||||
const enabled = plugins.filter(p => p.enabled);
|
||||
if (enabled.length === 0) {
|
||||
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||
return;
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of enabled) {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||
});
|
||||
if (!res.ok) failed++;
|
||||
}
|
||||
if (failed === 0) {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||
} else {
|
||||
await fetchPlugins();
|
||||
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlugin(id: string, name: string) {
|
||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Delete failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
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"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label 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 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Plugin
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||
</div>
|
||||
<button onClick={togglePluginsEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||
</div>
|
||||
<button onClick={togglePluginsUploadEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||
</div>
|
||||
<button onClick={toggleRequirePluginApproval}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={forceEnableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
onClick={forceDisableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerOff className="w-3.5 h-3.5" />
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||
</div>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{plugins.map(plugin => (
|
||||
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{plugin.forceEnabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Forced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{plugin.permissions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-xs text-warning">
|
||||
Permissions: {plugin.permissions.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/plugins/${plugin.id}`}
|
||||
title="Configure"
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'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';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
|
||||
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
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' },
|
||||
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
|
||||
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' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
};
|
||||
|
||||
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: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
|
||||
{ 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: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
|
||||
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
|
||||
{ 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 function PolicyTab() {
|
||||
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);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
}
|
||||
} finally {
|
||||
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 apiFetch('/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 flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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. Plugin and theme gates are on their respective admin pages.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
|
||||
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
|
||||
.map(key => {
|
||||
const meta = FEATURE_GATE_LABELS[key];
|
||||
if (!meta) return null;
|
||||
const { label, description } = meta;
|
||||
const enabled = policy.features[key];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<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={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{setting.label}</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<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,245 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
export function SettingsTab() {
|
||||
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 apiFetch('/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 apiFetch('/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 apiFetch('/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 flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<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} />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{label}</label>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-full sm: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="shrink-0 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm: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 shrink-0">
|
||||
<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/25 dark:bg-muted-foreground/50'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ interface PluginEntry {
|
||||
permissions: string[];
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
/** True when loaded from PLUGIN_DEV_DIR (read-only, managed via filesystem) */
|
||||
dev?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminPluginsPage() {
|
||||
@@ -396,6 +398,11 @@ export default function AdminPluginsPage() {
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{plugin.dev && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 dark:bg-violet-950/30 dark:text-violet-400" title="Loaded from PLUGIN_DEV_DIR — managed via filesystem">
|
||||
Dev
|
||||
</span>
|
||||
)}
|
||||
{plugin.forceEnabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Forced
|
||||
@@ -428,22 +435,25 @@ export default function AdminPluginsPage() {
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||
disabled={plugin.dev}
|
||||
title={plugin.dev ? 'Dev plugins are managed via filesystem' : plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||
className={`p-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={plugin.dev}
|
||||
title={plugin.dev ? 'Dev plugins are always enabled' : plugin.enabled ? 'Disable' : 'Enable'}
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
disabled={plugin.dev}
|
||||
title={plugin.dev ? 'Delete the folder in PLUGIN_DEV_DIR to remove' : 'Remove'}
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin } from '@/lib/admin/plugin-dev';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
@@ -21,11 +20,11 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Dev plugins are read straight from disk and served with no caching so
|
||||
// every refresh picks up the latest build.
|
||||
// Dev plugins are read (and optionally bundled) straight from disk and
|
||||
// served with no caching so every refresh picks up the latest source.
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readFile(devEntry.bundlePath, 'utf-8');
|
||||
const code = await readDevBundle(devEntry);
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
deletePlugin as removePlugin,
|
||||
type ServerPlugin,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
@@ -34,8 +35,20 @@ export async function GET() {
|
||||
const result = await requireAdminAuth();
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getPluginRegistry();
|
||||
return NextResponse.json(registry.plugins, {
|
||||
const [registry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
listDevPlugins(),
|
||||
]);
|
||||
|
||||
// Dev plugins win on id collision so admins see what users actually load.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const merged = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
|
||||
...registry.plugins
|
||||
.filter(p => !devIds.has(p.id))
|
||||
.map(p => ({ ...p, dev: false as const })),
|
||||
];
|
||||
return NextResponse.json(merged, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
+78
-25
@@ -8,27 +8,27 @@ import type { ServerPlugin } from './plugin-registry';
|
||||
/**
|
||||
* Dev-mode plugin loading.
|
||||
*
|
||||
* When the `PLUGIN_DEV_DIR` env var points at a directory, every immediate
|
||||
* subfolder is treated as a candidate plugin and merged into the registry
|
||||
* served to clients.
|
||||
* Set PLUGIN_DEV_DIR to a directory whose immediate subfolders are plugin
|
||||
* sources. Each subfolder must contain a `manifest.json`. The bundle file
|
||||
* (declared as `entrypoint` in the manifest) is resolved in this order:
|
||||
*
|
||||
* PLUGIN_DEV_DIR=/path/to/repos/plugins
|
||||
* 1. `src/<entrypoint>` → bundled on-demand via esbuild (preferred).
|
||||
* Lets you edit source files directly and just refresh the browser.
|
||||
* 2. `<entrypoint>` at the plugin root → served raw.
|
||||
* 3. `dist/<entrypoint>` → served raw (output of a manual build).
|
||||
*
|
||||
* Each subfolder must contain `manifest.json` and the entrypoint file. If a
|
||||
* `dist/` subdirectory exists with its own `manifest.json` (typical for
|
||||
* plugins built via esbuild) we use that instead — so no extra copy step is
|
||||
* needed during development.
|
||||
*
|
||||
* Dev plugins always win on id collision with admin-installed plugins, the
|
||||
* bundle is served with `Cache-Control: no-store`, and the bundle hash is
|
||||
* recomputed on every request so that any save propagates to all connected
|
||||
* clients on their next page refresh.
|
||||
* Bundles are recomputed on every request so any save in `src/` propagates
|
||||
* to all connected clients on their next page refresh. The content hash
|
||||
* doubles as the HTTP ETag and the `?v=` cache-buster.
|
||||
*/
|
||||
|
||||
export interface DevPluginEntry {
|
||||
plugin: ServerPlugin;
|
||||
/** Absolute path to either a source file (needs bundling) or a built file. */
|
||||
bundlePath: string;
|
||||
manifestPath: string;
|
||||
/** True when bundlePath points at an unbundled source file under `src/`. */
|
||||
needsBundle: boolean;
|
||||
}
|
||||
|
||||
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
@@ -58,15 +58,65 @@ async function readManifest(manifestPath: string): Promise<Record<string, unknow
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedBundle {
|
||||
bundlePath: string;
|
||||
needsBundle: boolean;
|
||||
}
|
||||
|
||||
function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundle | null {
|
||||
const srcCandidate = path.join(pluginDir, 'src', entrypoint);
|
||||
if (existsSync(srcCandidate)) return { bundlePath: srcCandidate, needsBundle: true };
|
||||
|
||||
const rootCandidate = path.join(pluginDir, entrypoint);
|
||||
if (existsSync(rootCandidate)) return { bundlePath: rootCandidate, needsBundle: false };
|
||||
|
||||
const distCandidate = path.join(pluginDir, 'dist', entrypoint);
|
||||
if (existsSync(distCandidate)) return { bundlePath: distCandidate, needsBundle: false };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
|
||||
* on every call so saves are reflected immediately. Errors are surfaced as
|
||||
* a JS module that throws at activation time — that way the dev sees the
|
||||
* failure in the browser console instead of a silent 404.
|
||||
*/
|
||||
export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
|
||||
if (!entry.needsBundle) {
|
||||
return readFile(entry.bundlePath, 'utf-8');
|
||||
}
|
||||
try {
|
||||
const esbuild = await import('esbuild');
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry.bundlePath],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
sourcemap: 'inline',
|
||||
target: ['es2020'],
|
||||
// React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
|
||||
// host, so we mark them external — the bundle won't try to ship them.
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
});
|
||||
const out = result.outputFiles?.[0]?.text;
|
||||
if (!out) throw new Error('esbuild produced no output');
|
||||
return out;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
|
||||
// Return a module that throws on load so the dev sees the error.
|
||||
return `throw new Error(${JSON.stringify(`[plugin-dev:${entry.plugin.id}] esbuild failed: ${message}`)});`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null> {
|
||||
// Prefer dist/ when present (bundled output) so devs don't have to copy
|
||||
// manifest.json around.
|
||||
const distDir = path.join(pluginDir, 'dist');
|
||||
let manifestPath = path.join(distDir, 'manifest.json');
|
||||
let baseDir = distDir;
|
||||
// Prefer the root manifest.json. Fall back to dist/manifest.json for
|
||||
// pre-built plugins that don't keep a manifest at the root.
|
||||
let manifestPath = path.join(pluginDir, 'manifest.json');
|
||||
if (!existsSync(manifestPath)) {
|
||||
manifestPath = path.join(pluginDir, 'manifest.json');
|
||||
baseDir = pluginDir;
|
||||
manifestPath = path.join(pluginDir, 'dist', 'manifest.json');
|
||||
}
|
||||
if (!existsSync(manifestPath)) return null;
|
||||
|
||||
@@ -76,12 +126,15 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
if (!PLUGIN_ID_RE.test(id)) return null;
|
||||
|
||||
const entrypoint = asString(manifest.entrypoint, 'index.js');
|
||||
const bundlePath = path.join(baseDir, entrypoint);
|
||||
if (!existsSync(bundlePath)) return null;
|
||||
const resolved = resolveBundlePath(pluginDir, entrypoint);
|
||||
if (!resolved) return null;
|
||||
|
||||
// Hash from the on-disk source so any edit propagates. For src/ sources
|
||||
// we hash the source — close enough for dev-time change detection (we
|
||||
// don't need to re-hash transitive imports).
|
||||
let bundleHash: string;
|
||||
try {
|
||||
const code = await readFile(bundlePath);
|
||||
const code = await readFile(resolved.bundlePath);
|
||||
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -89,7 +142,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
|
||||
let installedAt = new Date().toISOString();
|
||||
try {
|
||||
const stats = await stat(bundlePath);
|
||||
const stats = await stat(resolved.bundlePath);
|
||||
installedAt = stats.mtime.toISOString();
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -117,7 +170,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
updatedAt: new Date().toISOString(),
|
||||
bundleHash,
|
||||
};
|
||||
return { plugin, bundlePath, manifestPath };
|
||||
return { plugin, bundlePath: resolved.bundlePath, manifestPath, needsBundle: resolved.needsBundle };
|
||||
}
|
||||
|
||||
export async function listDevPlugins(): Promise<DevPluginEntry[]> {
|
||||
|
||||
@@ -42,6 +42,10 @@ const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ["192.168.1.51"],
|
||||
basePath: basePath || undefined,
|
||||
// esbuild ships native binaries + a README the bundler can't parse; load
|
||||
// it from node_modules at runtime instead of trying to bundle it. Used by
|
||||
// PLUGIN_DEV_DIR's on-the-fly bundler.
|
||||
serverExternalPackages: ["esbuild"],
|
||||
turbopack: {
|
||||
root: import.meta.dirname,
|
||||
},
|
||||
|
||||
Generated
+108
-135
@@ -59,6 +59,7 @@
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -602,9 +603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -614,15 +615,14 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -632,15 +632,14 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -650,15 +649,14 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -668,15 +666,14 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -686,15 +683,14 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -704,15 +700,14 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -722,15 +717,14 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -740,15 +734,14 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -758,15 +751,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -776,15 +768,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -794,15 +785,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -812,15 +802,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -830,15 +819,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -848,15 +836,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -866,15 +853,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -884,15 +870,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -902,15 +887,14 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -920,15 +904,14 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -938,15 +921,14 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -956,15 +938,14 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -974,15 +955,14 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -992,15 +972,14 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1010,15 +989,14 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1028,15 +1006,14 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1046,15 +1023,14 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1064,7 +1040,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -5469,14 +5444,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -5484,32 +5457,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
Reference in New Issue
Block a user