diff --git a/app/admin/_tabs/auth.tsx b/app/admin/_tabs/auth.tsx new file mode 100644 index 00000000..274d3925 --- /dev/null +++ b/app/admin/_tabs/auth.tsx @@ -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>({}); + const [edits, setEdits] = useState>({}); + 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
Loading...
; + } + + return ( +
+
+
+

Authentication

+

OAuth, SSO, and session configuration

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+
+ +

Auto-configure OAuth (Stalwart)

+
+

+ 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. +

+
+ +
+
+ + {setupOpen && ( +
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} + > +
+
+

Auto-configure OAuth

+

+ Verify the URLs below before continuing. The webmail and Stalwart can live on different domains. +

+
+
+
+ + 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" + /> +

+ Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart. +

+ {!setupOriginValid && setupOrigin.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+
+ + 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" + /> +

+ Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL. +

+ {!setupIssuerValid && setupIssuer.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+ +
+
+ + +
+
+
+ )} + +
+ + + + + +
+ +
+ +
+ +
+ 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' && ( + + )} +
+ + ); +} + +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 ( +
+
+
+ {label} + +
+ {description &&

{description}

} +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} + +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 ( +
+
+ {label} + +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} diff --git a/app/admin/_tabs/branding.tsx b/app/admin/_tabs/branding.tsx new file mode 100644 index 00000000..e5e3f1ca --- /dev/null +++ b/app/admin/_tabs/branding.tsx @@ -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>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRefs = useRef>({}); + + 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
Loading...
; + } + + return ( +
+
+
+

Branding

+

Customize logos, favicon, and company information

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

Images & Logos

+

Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)

+
+
+ {IMAGE_FIELDS.map(field => ( +
+
+
+ + {config[field.key]?.source === 'admin' && ( + + {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + + )} +
+
+ 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" + /> + { 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 = ''; + }} + /> + + {isUploadedFile(field.key) && ( + + )} + {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + + )} +
+
+ {currentValue(field.key) && ( +
+ +
+ {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ )} +
+ ))} +
+
+ +
+
+

Company Information

+
+
+ {TEXT_FIELDS.map(field => ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ 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' && ( + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/admin/_tabs/dashboard.tsx b/app/admin/_tabs/dashboard.tsx new file mode 100644 index 00000000..558d8414 --- /dev/null +++ b/app/admin/_tabs/dashboard.tsx @@ -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(null); + const [recentActivity, setRecentActivity] = useState([]); + const [config, setConfig] = useState(null); + const [, setConfigSources] = useState | null>(null); + const [warnings, setWarnings] = useState([]); + 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 ( +
+ {warnings.map((msg, i) => ( +
+ +

{msg}

+
+ ))} + + {status && !status.lastLogin && ( +
+ +
+

First login detected

+

+ Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely. +

+
+
+ )} + + + + {config?.appName || '-'} + + + {jmapHostname} + + + + + {jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'} + + + + + {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} + + + + + + + {}} disabled /> + + + {}} disabled /> + + + {}} disabled /> + + + {}} disabled /> + + + + + + {accountCounts?.total ?? '-'} + + + {accountCounts?.active7d ?? '-'} + + + + + + {pluginCount} + + + {themeCount} + + + {policyRuleCount} + + + + + {recentActivity.length === 0 ? ( +
+ No activity recorded yet +
+ ) : ( + recentActivity.map((entry, i) => ( + +
+ {entry.ip} + {new Date(entry.ts).toLocaleString()} +
+
+ )) + )} +
+
+ ); +} + +function formatDetail(detail: Record): 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); +} diff --git a/app/admin/_tabs/plugins.tsx b/app/admin/_tabs/plugins.tsx new file mode 100644 index 00000000..dcd7cc3e --- /dev/null +++ b/app/admin/_tabs/plugins.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRef = useRef(null); + const [policy, setPolicy] = useState({ ...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) { + 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 = { 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
Loading...
; + } + + const pluginsEnabled = policy.features.pluginsEnabled ?? true; + const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; + const requirePluginApproval = policy.features.requirePluginApproval ?? true; + + return ( +
+
+
+

Plugins

+

Manage plugins and plugin policy for all users

+
+
+ {policyDirty && ( + + )} + +
+
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+ +

Plugin Policy

+
+

Control plugin availability for users

+
+
+
+
+ Plugins Enabled +

Allow the plugin system to load and run plugins for users

+
+ +
+ +
+
+ User Plugin Uploads +

Allow users to upload plugin ZIP files in Settings

+
+ +
+ +
+
+ Require Admin Approval +

User-uploaded plugins must be approved by an admin before they can be enabled

+
+ +
+ + {plugins.length > 0 && ( +
+
+ Force Enable / Disable All +

Bulk toggle all deployed plugins at once

+
+
+ + +
+
+ )} +
+
+ +
+
+
+ +

Deployed Plugins

+
+

Admin-uploaded plugins for all users

+
+ {plugins.length === 0 ? ( +
+ +

No plugins installed

+

Upload a plugin ZIP file to get started

+
+ ) : ( +
+ {plugins.map(plugin => ( +
+
+
+ {plugin.name} + v{plugin.version} + + {plugin.enabled ? 'Enabled' : 'Disabled'} + + {plugin.forceEnabled && ( + + Forced + + )} +
+ {plugin.description && ( +

{plugin.description}

+ )} +
+ by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()} +
+ {plugin.permissions.length > 0 && ( +
+ + + Permissions: {plugin.permissions.join(', ')} + +
+ )} +
+ +
+ + + + + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/admin/_tabs/policy.tsx b/app/admin/_tabs/policy.tsx new file mode 100644 index 00000000..5295265d --- /dev/null +++ b/app/admin/_tabs/policy.tsx @@ -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> = { + 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({ ...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
Loading...
; + } + + const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; + + return ( +
+
+
+

User Policy

+

Control which features and settings users can access

+
+ {dirty && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

Feature Gates

+

Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.

+
+
+ {(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 ( +
+
+ {label} +

{description}

+
+ +
+ ); + })} +
+
+ + {categories.map(category => ( +
+
+

{category}

+
+
+ {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { + const restriction = policy.restrictions[setting.key] || {}; + return ( +
+ {setting.label} +
+ + +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/app/admin/_tabs/settings.tsx b/app/admin/_tabs/settings.tsx new file mode 100644 index 00000000..af8f44f1 --- /dev/null +++ b/app/admin/_tabs/settings.tsx @@ -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>({}); + const [edits, setEdits] = useState>({}); + 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
Loading...
; + } + + return ( +
+
+
+

Server Settings

+

General server configuration

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + + + + + + {!!currentValue('allowCustomJmapEndpoint') && ( +
+

+ CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked. +

+
+ )} + + +
+ + + + + + + + + +
+ ); +} + +function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
+ {children} +
+
+ ); +} + +function SourceBadge({ source }: { source?: string }) { + if (!source || source === 'default') return null; + return ( + + {source} + + ); +} + +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 ( +
+
+ + +
+
+ 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' && ( + + )} +
+
+ ); +} + +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 ( +
+
+
+ {label} + +
+ {description &&

{description}

} +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} + +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 ( +
+
+ {label} + +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx index 9c3bd015..63f076bd 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -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() { {plugin.enabled ? 'Enabled' : 'Disabled'} + {plugin.dev && ( + + Dev + + )} {plugin.forceEnabled && ( Forced @@ -428,22 +435,25 @@ export default function AdminPluginsPage() { diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index d4fa1610..bf8db9e5 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -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', diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 491dca0b..3f1f4e12 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -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) { diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 5e3f7da8..6f3b1bf8 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -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/` → bundled on-demand via esbuild (preferred). + * Lets you edit source files directly and just refresh the browser. + * 2. `` at the plugin root → served raw. + * 3. `dist/` → 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 { + 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 { - // 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 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 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 updatedAt: new Date().toISOString(), bundleHash, }; - return { plugin, bundlePath, manifestPath }; + return { plugin, bundlePath: resolved.bundlePath, manifestPath, needsBundle: resolved.needsBundle }; } export async function listDevPlugins(): Promise { diff --git a/next.config.ts b/next.config.ts index 13f6cc77..cdb98b7a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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, }, diff --git a/package-lock.json b/package-lock.json index ad31f5b0..72a707f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index ca349352..18ac8a02 100644 --- a/package.json +++ b/package.json @@ -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",