From e7264f521c253819aca4c9c75c0cb67b5a94e0db Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 18:50:57 +0200 Subject: [PATCH] fix: collapse admin panel into single tabbed page --- app/admin/_tabs/logs.tsx | 174 ++++++++++ app/admin/_tabs/marketplace.tsx | 353 ++++++++++++++++++++ app/admin/_tabs/telemetry.tsx | 250 ++++++++++++++ app/admin/_tabs/themes.tsx | 545 +++++++++++++++++++++++++++++++ app/admin/_tabs/version.tsx | 237 ++++++++++++++ app/admin/auth/page.tsx | 384 +--------------------- app/admin/branding/page.tsx | 301 +---------------- app/admin/layout.tsx | 60 ++-- app/admin/logs/page.tsx | 179 +---------- app/admin/marketplace/page.tsx | 367 +-------------------- app/admin/page.tsx | 263 +++------------ app/admin/plugins/page.tsx | 469 +-------------------------- app/admin/policy/page.tsx | 221 +------------ app/admin/settings/page.tsx | 249 +------------- app/admin/telemetry/page.tsx | 251 +-------------- app/admin/themes/page.tsx | 554 +------------------------------- app/admin/version/page.tsx | 238 +------------- stores/admin-tab-store.ts | 41 +++ 18 files changed, 1710 insertions(+), 3426 deletions(-) create mode 100644 app/admin/_tabs/logs.tsx create mode 100644 app/admin/_tabs/marketplace.tsx create mode 100644 app/admin/_tabs/telemetry.tsx create mode 100644 app/admin/_tabs/themes.tsx create mode 100644 app/admin/_tabs/version.tsx create mode 100644 stores/admin-tab-store.ts diff --git a/app/admin/_tabs/logs.tsx b/app/admin/_tabs/logs.tsx new file mode 100644 index 00000000..76ba13d8 --- /dev/null +++ b/app/admin/_tabs/logs.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { RefreshCw } from 'lucide-react'; +import type { AuditEntry } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; + +export function LogsTab() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [actionFilter, setActionFilter] = useState(''); + const limit = 50; + + const fetchLogs = useCallback(async () => { + setLoading(true); + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (actionFilter) params.set('action', actionFilter); + + const res = await apiFetch(`/api/admin/audit?${params}`); + if (res.ok) { + const data = await res.json(); + setEntries(data.entries || []); + setTotal(data.total || 0); + } + setLoading(false); + }, [page, actionFilter]); + + useEffect(() => { fetchLogs(); }, [fetchLogs]); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return ( +
+
+
+

Audit Log

+

{total} total entries

+
+ +
+ +
+ +
+ +
+ {loading && entries.length === 0 ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
No entries found
+ ) : ( + entries.map((entry, i) => ( +
+
+ + {entry.action} + + + {new Date(entry.ts).toLocaleString()} + +
+
+ {formatDetail(entry.detail)} +
+
+ {entry.ip} +
+
+ )) + )} +
+ +
+ + + + + + + + + + + {loading && entries.length === 0 ? ( + + + + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map((entry, i) => ( + + + + + + + )) + )} + +
TimeActionDetailsIP
Loading...
No entries found
+ {new Date(entry.ts).toLocaleString()} + + + {entry.action} + + + {formatDetail(entry.detail)} + + {entry.ip} +
+
+ + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +} + +function formatDetail(detail: Record): string { + if (!detail || Object.keys(detail).length === 0) return '-'; + if (detail.reason) return String(detail.reason); + if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`; + if (detail.changes && Array.isArray(detail.changes)) { + return detail.changes.map((c: Record) => `${c.key}`).join(', '); + } + if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`; + return JSON.stringify(detail).slice(0, 100); +} diff --git a/app/admin/_tabs/marketplace.tsx b/app/admin/_tabs/marketplace.tsx new file mode 100644 index 00000000..b2e66311 --- /dev/null +++ b/app/admin/_tabs/marketplace.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface Extension { + slug: string; + name: string; + type: 'plugin' | 'theme'; + pluginType: string | null; + description: string; + permissions: string[]; + tags: string[]; + totalDownloads: number; + featured: boolean; + minAppVersion: string | null; + latestVersion: string | null; + installed: boolean; + author: { + displayName: string; + githubLogin: string; + avatarUrl: string | null; + } | null; +} + +interface SearchResult { + data: Extension[]; + meta: { + page: number; + perPage: number; + total: number; + }; +} + +type TypeFilter = 'all' | 'plugin' | 'theme'; + +export function MarketplaceTab() { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [perPage] = useState(12); + const [installing, setInstalling] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [error, setError] = useState(null); + + const fetchExtensions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (typeFilter !== 'all') params.set('type', typeFilter); + params.set('page', String(page)); + params.set('perPage', String(perPage)); + params.set('sort', 'newest'); + + const res = await apiFetch(`/api/admin/marketplace?${params}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || 'Failed to connect to extension directory'); + setExtensions([]); + return; + } + + const data: SearchResult = await res.json(); + setExtensions(data.data || []); + setTotal(data.meta?.total || 0); + } catch { + setError('Failed to connect to extension directory. Make sure it is running.'); + setExtensions([]); + } finally { + setLoading(false); + } + }, [query, typeFilter, page, perPage]); + + useEffect(() => { + fetchExtensions(); + }, [fetchExtensions]); + + const [searchInput, setSearchInput] = useState(''); + useEffect(() => { + const t = setTimeout(() => { + setQuery(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + async function handleInstall(ext: Extension) { + setInstalling(ext.slug); + setMessage(null); + + try { + const res = await apiFetch('/api/admin/marketplace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug: ext.slug, + version: ext.latestVersion || '1.0.0', + type: ext.type, + }), + }); + + const data = await res.json(); + + if (res.ok) { + const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; + setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); + setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); + } else { + setMessage({ type: 'error', text: data.error || 'Installation failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Installation failed - network error' }); + } finally { + setInstalling(null); + } + } + + const totalPages = Math.ceil(total / perPage); + + return ( +
+
+

Marketplace

+

+ Browse and install plugins and themes from the BulwarkMail extension directory +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+ + setSearchInput(e.target.value)} + className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" + /> +
+
+ {(['all', 'plugin', 'theme'] as const).map((t) => ( + + ))} +
+
+ + {error && ( +
+ +

{error}

+

+ Start the extension directory server on the configured port +

+ +
+ )} + + {loading && !error && ( +
+ + Searching extensions... +
+ )} + + {!loading && !error && extensions.length === 0 && ( +
+ +

No extensions found

+ {query && ( +

+ Try a different search term +

+ )} +
+ )} + + {!loading && !error && extensions.length > 0 && ( + <> +
+ {total} extension{total !== 1 ? 's' : ''} found +
+
+ {extensions.map((ext) => ( + handleInstall(ext)} + /> + ))} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + )} +
+ ); +} + +function ExtensionCard({ + extension, + installing, + onInstall, +}: { + extension: Extension; + installing: boolean; + onInstall: () => void; +}) { + const isPlugin = extension.type === 'plugin'; + const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; + + return ( +
+ +
+
+ {isPlugin ? ( + + ) : ( + + )} +
+
+
+ + {extension.name} + + {extension.featured && ( + + )} +
+
+ + {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} + + {extension.author && ( + + by {extension.author.displayName} + + )} +
+
+
+ +

+ {extension.description} +

+ + {extension.tags && extension.tags.length > 0 && ( +
+ {extension.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+ )} + +
+
+ + + {extension.totalDownloads.toLocaleString()} + + {extension.permissions && extension.permissions.length > 0 && ( + + {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} + + )} +
+ + + Preview + +
+ + +
+ {extension.installed ? ( + + + Installed + + ) : ( + + )} +
+
+ ); +} diff --git a/app/admin/_tabs/telemetry.tsx b/app/admin/_tabs/telemetry.tsx new file mode 100644 index 00000000..6ff3b760 --- /dev/null +++ b/app/admin/_tabs/telemetry.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface TelemetryStatus { + consent: 'pending' | 'on' | 'off'; + consentSource: 'env' | 'file'; + endpoint: string; + defaultEndpoint: string; + consentedAt: string | null; + lastSentAt: string | null; + nextScheduledAt: string | null; + payloadPreview: Record; + accountCounts: { total: number; active7d: number }; +} + +function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 0) return new Date(iso).toLocaleString(); + const m = Math.floor(d / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} hours ago`; + const days = Math.floor(h / 24); + return `${days} days ago`; +} + +export function TelemetryTab() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [endpointDraft, setEndpointDraft] = useState(''); + const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function refresh(): Promise { + setLoading(true); + try { + const r = await apiFetch('/api/admin/telemetry'); + if (!r.ok) throw new Error('failed to load'); + const data = (await r.json()) as TelemetryStatus; + setStatus(data); + setEndpointDraft(data.endpoint); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { void refresh(); }, []); + + async function setConsent(consent: 'on' | 'off'): Promise { + setBusy('consent'); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set-consent', consent }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + alert(j.error ?? 'failed'); + } + await refresh(); + } finally { setBusy(null); } + } + + async function saveEndpoint(): Promise { + setBusy('endpoint'); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + alert(j.error ?? 'failed'); + } + await refresh(); + } finally { setBusy(null); } + } + + async function sendNow(): Promise { + setBusy('send'); + setSendResult(null); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'send-now' }), + }); + const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string }; + setSendResult({ + ok: !!j.ok, + msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`, + }); + await refresh(); + } finally { setBusy(null); } + } + + if (loading || !status) { + return ( +
+ loading… +
+ ); + } + + const envOverridden = status.consentSource === 'env'; + const isOn = status.consent === 'on'; + + return ( +
+
+

Anonymous Usage Stats

+

+ Bulwark sends one anonymous heartbeat per day so we can see how many instances are + running, on what platforms, and which features they use. Enabled by default; + one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} + + Full schema and policy + +

+
+ +
+
+
+
Status
+
+ {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} + {status.consent === 'on' && 'Heartbeats are enabled (default).'} + {status.consent === 'off' && 'Heartbeats are off.'} + {envOverridden && ( + <> Locked by BULWARK_TELEMETRY env var. + )} +
+
+
+ + +
+
+
+
Last sent
+
{timeAgo(status.lastSentAt)}
+
Next scheduled
+
{timeAgo(status.nextScheduledAt)}
+
Consented at
+
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
+
+
+ +
+
Account activity
+

+ Unique accounts that have logged in over the last 90 days. Identities are stored as a + per-instance HMAC, never as plaintext usernames. These are the numbers reported in the + heartbeat as bucketed ranges. +

+
+
Total (90d)
+
{status.accountCounts?.total ?? 0}
+
Active (7d)
+
{status.accountCounts?.active7d ?? 0}
+
+
+ +
+
Endpoint
+

+ Where heartbeats are sent. Defaults to the project's collector. Point at your own collector + (open source at bulwarkmail/dashboard) or clear this field to disable sending. +

+
+ setEndpointDraft(e.target.value)} + placeholder={status.defaultEndpoint} + className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background" + /> + +
+
+ +
+
+
+
Payload preview
+
+ Exactly what the next heartbeat would send from this install, right now. +
+
+ +
+ {sendResult && ( +
+ {sendResult.ok ? : } + {sendResult.msg} +
+ )} +
+          {JSON.stringify(status.payloadPreview, null, 2)}
+        
+
+
+ ); +} diff --git a/app/admin/_tabs/themes.tsx b/app/admin/_tabs/themes.tsx new file mode 100644 index 00000000..80c32759 --- /dev/null +++ b/app/admin/_tabs/themes.tsx @@ -0,0 +1,545 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; + +const BUILTIN_THEME_OPTIONS = [ + { id: 'builtin-nord', name: 'Nord' }, + { id: 'builtin-catppuccin', name: 'Catppuccin' }, + { id: 'builtin-solarized', name: 'Solarized' }, +]; + +interface ThemeEntry { + id: string; + name: string; + version: string; + author: string; + description: string; + variants: string[]; + enabled: boolean; + forceEnabled?: boolean; + installedAt: string; + updatedAt: string; +} + +export function ThemesTab() { + const [themes, setThemes] = 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(() => { fetchThemes(); fetchPolicy(); }, []); + + async function fetchPolicy() { + try { + const res = await apiFetch('/api/admin/policy'); + if (res.ok) { + const data = await res.json(); + setPolicy({ + ...data, + themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) }, + }); + } + } catch { /* ignore */ } + } + + function toggleThemesEnabled() { + setPolicy(prev => ({ + ...prev, + features: { ...prev.features, themesEnabled: !prev.features.themesEnabled }, + })); + setPolicyDirty(true); + setMessage(null); + } + + function toggleUserThemeUploads() { + setPolicy(prev => ({ + ...prev, + features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled }, + })); + setPolicyDirty(true); + setMessage(null); + } + + function toggleBuiltinTheme(themeId: string) { + setPolicy(prev => { + const disabled = prev.themePolicy?.disabledBuiltinThemes || []; + const isDisabled = disabled.includes(themeId); + return { + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + disabledBuiltinThemes: isDisabled + ? disabled.filter((id: string) => id !== themeId) + : [...disabled, themeId], + }, + }; + }); + setPolicyDirty(true); + setMessage(null); + } + + function toggleAdminTheme(themeId: string) { + setPolicy(prev => { + const disabled = prev.themePolicy?.disabledThemes || []; + const isDisabled = disabled.includes(themeId); + return { + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + disabledThemes: isDisabled + ? disabled.filter((id: string) => id !== themeId) + : [...disabled, themeId], + }, + }; + }); + setPolicyDirty(true); + setMessage(null); + } + + function setDefaultTheme(themeId: string | null) { + setPolicy(prev => ({ + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + defaultThemeId: themeId, + }, + })); + 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: 'Theme 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 fetchThemes() { + setLoading(true); + try { + const res = await apiFetch('/api/admin/themes'); + if (res.ok) setThemes(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/themes', { + 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: `Theme "${data.theme.name}" installed${warnings}` }); + await fetchThemes(); + } 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 toggleTheme(id: string, enabled: boolean) { + setMessage(null); + const res = await apiFetch('/api/admin/themes', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, enabled }), + }); + + if (res.ok) { + setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t)); + } 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/themes', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (res.ok) { + setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t)); + setPolicy(prev => { + const current = prev.forceEnabledThemes || []; + return { + ...prev, + forceEnabledThemes: forceEnabled + ? [...current.filter(tid => tid !== id), id] + : current.filter(tid => tid !== id), + }; + }); + setPolicyDirty(true); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Update failed' }); + } + } + + async function forceEnableAll() { + setMessage(null); + const disabled = themes.filter(t => !t.enabled); + if (disabled.length === 0) { + setMessage({ type: 'success', text: 'All themes are already enabled' }); + return; + } + let failed = 0; + for (const t of disabled) { + const res = await apiFetch('/api/admin/themes', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: t.id, enabled: true }), + }); + if (!res.ok) failed++; + } + if (failed === 0) { + await fetchThemes(); + setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` }); + } else { + await fetchThemes(); + setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` }); + } + } + + async function forceDisableAll() { + setMessage(null); + const enabled = themes.filter(t => t.enabled); + if (enabled.length === 0) { + setMessage({ type: 'success', text: 'All themes are already disabled' }); + return; + } + let failed = 0; + for (const t of enabled) { + const res = await apiFetch('/api/admin/themes', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: t.id, enabled: false }), + }); + if (!res.ok) failed++; + } + if (failed === 0) { + await fetchThemes(); + setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` }); + } else { + await fetchThemes(); + setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` }); + } + } + + async function deleteTheme(id: string, name: string) { + if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return; + + setMessage(null); + const res = await apiFetch('/api/admin/themes', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + + if (res.ok) { + setThemes(prev => prev.filter(t => t.id !== id)); + setMessage({ type: 'success', text: `Theme "${name}" removed` }); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Delete failed' }); + } + } + + if (loading) { + return
Loading...
; + } + + const themesEnabled = policy.features.themesEnabled ?? true; + const userThemesEnabled = policy.features.userThemesEnabled ?? true; + + return ( +
+
+
+

Themes

+

Manage themes and theme policy for all users

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

Theme Policy

+
+

Control theme availability and defaults for users

+
+ +
+
+
+ Themes Enabled +

Allow users to select and apply themes

+
+ +
+ +
+
+ User Theme Uploads +

Allow users to upload their own theme files

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

Bulk toggle all deployed themes at once

+
+
+ + +
+
+ )} + +
+
+
+ Default Theme +

Theme applied when users have not chosen one

+
+ +
+
+ +
+ Built-in Themes +
+ {BUILTIN_THEME_OPTIONS.map(theme => { + const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id); + return ( +
+ {theme.name} + +
+ ); + })} +
+
+ + {themes.length > 0 && ( +
+ Admin-deployed Themes +
+ {themes.map(theme => { + const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id); + return ( +
+ {theme.name} + +
+ ); + })} +
+
+ )} +
+
+ +
+
+
+ +

Deployed Themes

+
+

Admin-uploaded themes available to all users

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

No themes installed

+

Upload a theme ZIP file to get started

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

{theme.description}

+ )} +
+ by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()} +
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/admin/_tabs/version.tsx b/app/admin/_tabs/version.tsx new file mode 100644 index 00000000..e82241a2 --- /dev/null +++ b/app/admin/_tabs/version.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Loader2, + RefreshCw, + CheckCircle2, + AlertTriangle, + ShieldAlert, + ExternalLink, +} from 'lucide-react'; +import { SettingsSection, SettingItem } from '@/components/settings/settings-section'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; + +interface VersionAdminStatus { + current: string; + build: string; + endpoint: string; + defaultEndpoint: string; + disabledByEnv: boolean; + lastCheckedAt: string | null; + lastSuccessAt: string | null; + nextScheduledAt: string | null; + status: UpdateStatus | null; +} + +function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 0) return new Date(iso).toLocaleString(); + const m = Math.floor(d / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} hours ago`; + return `${Math.floor(h / 24)} days ago`; +} + +function severityChip(severity: UpdateSeverity) { + switch (severity) { + case 'security': + return { + label: 'Security update', + className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', + Icon: ShieldAlert, + }; + case 'deprecated': + return { + label: 'Deprecated', + className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', + Icon: ShieldAlert, + }; + case 'normal': + return { + label: 'Update available', + className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30', + Icon: AlertTriangle, + }; + case 'unknown': + return { + label: 'Unknown', + className: 'bg-muted text-muted-foreground border-border', + Icon: AlertTriangle, + }; + case 'none': + default: + return { + label: 'Up to date', + className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30', + Icon: CheckCircle2, + }; + } +} + +export function VersionTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function refresh(): Promise { + setLoading(true); + try { + const r = await apiFetch('/api/admin/version'); + if (!r.ok) throw new Error('failed to load'); + setData((await r.json()) as VersionAdminStatus); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { void refresh(); }, []); + + async function checkNow(): Promise { + setChecking(true); + setCheckResult(null); + try { + const r = await apiFetch('/api/admin/version', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'check-now' }), + }); + const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string }; + setCheckResult({ + ok: !!j.ok, + msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`, + }); + await refresh(); + } finally { + setChecking(false); + } + } + + if (loading || !data) { + return ( +
+ loading… +
+ ); + } + + const status = data.status; + const chip = severityChip(status?.severity ?? 'none'); + const ChipIcon = chip.Icon; + const releaseUrl = status?.url ?? null; + const newer = status?.latest && status.latest !== data.current ? status.latest : null; + + return ( +
+
+
+

Version

+

+ Hourly check against the Bulwark version server. Severity is decided server-side and + disable with BULWARK_UPDATE_CHECK=off. +

+
+ +
+ + {checkResult && ( +
+ {checkResult.msg} +
+ )} + + + + + + {chip.label} + + + + {data.current} + + {newer && ( + + {releaseUrl ? ( + + {newer} + + ) : ( + {newer} + )} + + )} + {status?.advisory && ( + + {status.advisory} + + )} + + + + + {timeAgo(data.lastCheckedAt)} + + + {timeAgo(data.lastSuccessAt)} + + + {timeAgo(data.nextScheduledAt)} + + {status?.checkedAt && ( + + {new Date(status.checkedAt).toLocaleString()} + + )} + + + + + + {data.endpoint} + + + + + {data.disabledByEnv ? 'Yes' : 'No'} + + + +
+ ); +} diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 0bdfea23..a637439a 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -1,383 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -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 default function AdminAuthPage() { - 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-setup */} -
-
-
-
- -

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

-
- -
-
- - {/* Auto-setup dialog */} - {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.

- )} -
- -
-
- - -
-
-
- )} - - {/* OAuth */} -
- - - - - -
- - {/* SSO */} -
- -
- - {/* Session & Security */} -
- 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' && ( - - )} -
-
- ); +export default function Page() { + redirect('/admin?tab=auth'); } diff --git a/app/admin/branding/page.tsx b/app/admin/branding/page.tsx index 6d1a1e94..cc6f73b7 100644 --- a/app/admin/branding/page.tsx +++ b/app/admin/branding/page.tsx @@ -1,300 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -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 default function AdminBrandingPage() { - 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.` }); - // Remove any pending URL edit for this slot since upload sets it - setEdits(prev => { - const next = { ...prev }; - delete next[slot]; - return next; - }); - // Update config to reflect the uploaded URL - setConfig(prev => ({ - ...prev, - [slot]: { value: data.url, source: 'admin' }, - })); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Upload failed' }); - } - setUploading(null); - } - - async function handleDeleteUpload(slot: string) { - setMessage(null); - - const res = await 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) && ( - - )} -
-
- {/* Preview */} - {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' && ( - - )} -
-
- ))} -
-
-
- ); +export default function Page() { + redirect('/admin?tab=branding'); } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 48737ae3..693f0289 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useRouter, usePathname } from 'next/navigation'; import Link from 'next/link'; +import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store'; import { LayoutDashboard, Settings, @@ -20,7 +21,6 @@ import { Calendar, BookUser, HardDrive, - ArrowLeft, Store, Menu, X, @@ -30,40 +30,46 @@ import { useConfig } from '@/hooks/use-config'; import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; -import { useAuthStore } from '@/stores/auth-store'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { apiFetch } from '@/lib/browser-navigation'; -const NAV_GROUPS = [ +// Single-page tab navigation: clicks update a Zustand store. The URL stays +// at /admin so React doesn't fire a route transition on every tab switch - +// matches the regular settings page pattern, fixes the dev-mode "Rendering…" +// hang we saw with both /admin/ routes and ?tab= search params. +const NAV_GROUPS: ReadonlyArray<{ + label: string; + items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>; +}> = [ { label: 'Overview', items: [ - { href: '/admin', label: 'Dashboard', icon: LayoutDashboard }, + { tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, ], }, { label: 'Configuration', items: [ - { href: '/admin/settings', label: 'Settings', icon: Settings }, - { href: '/admin/branding', label: 'Branding', icon: Palette }, - { href: '/admin/auth', label: 'Authentication', icon: Shield }, - { href: '/admin/policy', label: 'Policy', icon: Scale }, + { tab: 'settings', label: 'Settings', icon: Settings }, + { tab: 'branding', label: 'Branding', icon: Palette }, + { tab: 'auth', label: 'Authentication', icon: Shield }, + { tab: 'policy', label: 'Policy', icon: Scale }, ], }, { label: 'Extensions', items: [ - { href: '/admin/plugins', label: 'Plugins', icon: Puzzle }, - { href: '/admin/themes', label: 'Themes', icon: SwatchBook }, - { href: '/admin/marketplace', label: 'Marketplace', icon: Store }, + { tab: 'plugins', label: 'Plugins', icon: Puzzle }, + { tab: 'themes', label: 'Themes', icon: SwatchBook }, + { tab: 'marketplace', label: 'Marketplace', icon: Store }, ], }, { label: 'System', items: [ - { href: '/admin/version', label: 'Version', icon: Package }, - { href: '/admin/telemetry', label: 'Telemetry', icon: Activity }, - { href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, + { tab: 'version', label: 'Version', icon: Package }, + { tab: 'telemetry', label: 'Telemetry', icon: Activity }, + { tab: 'logs', label: 'Audit Log', icon: ScrollText }, ], }, ]; @@ -71,6 +77,11 @@ const NAV_GROUPS = [ export default function AdminLayout({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); + const storeActiveTab = useAdminTabStore((s) => s.activeTab); + const setActiveTab = useAdminTabStore((s) => s.setActiveTab); + // Highlight the active tab only on /admin itself - on dynamic routes + // (e.g. /admin/plugins/[id]) no tab is "current". + const activeTab = pathname === '/admin' ? storeActiveTab : null; const [authenticated, setAuthenticated] = useState(null); const [authError, setAuthError] = useState(null); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); @@ -178,13 +189,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {group.label} - {group.items.map(({ href, label, icon: Icon }) => { - const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href); - const showDot = href === '/admin/version' && hasUpdate; + {group.items.map(({ tab, label, icon: Icon }) => { + const active = activeTab === tab; + const showDot = tab === 'version' && hasUpdate; + const handleClick = () => { + setActiveTab(tab); + // From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug]) + // we still need a real navigation back to /admin so the page renders. + if (pathname !== '/admin') router.push('/admin'); + }; return ( - {label} - + ); })} diff --git a/app/admin/logs/page.tsx b/app/admin/logs/page.tsx index a683d290..669b675a 100644 --- a/app/admin/logs/page.tsx +++ b/app/admin/logs/page.tsx @@ -1,178 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useCallback } from 'react'; -import { RefreshCw } from 'lucide-react'; -import type { AuditEntry } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -export default function AdminLogsPage() { - const [entries, setEntries] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(true); - const [actionFilter, setActionFilter] = useState(''); - const limit = 50; - - const fetchLogs = useCallback(async () => { - setLoading(true); - const params = new URLSearchParams({ page: String(page), limit: String(limit) }); - if (actionFilter) params.set('action', actionFilter); - - const res = await apiFetch(`/api/admin/audit?${params}`); - if (res.ok) { - const data = await res.json(); - setEntries(data.entries || []); - setTotal(data.total || 0); - } - setLoading(false); - }, [page, actionFilter]); - - useEffect(() => { fetchLogs(); }, [fetchLogs]); - - const totalPages = Math.max(1, Math.ceil(total / limit)); - - return ( -
-
-
-

Audit Log

-

{total} total entries

-
- -
- - {/* Filter */} -
- -
- - {/* Mobile cards */} -
- {loading && entries.length === 0 ? ( -
Loading...
- ) : entries.length === 0 ? ( -
No entries found
- ) : ( - entries.map((entry, i) => ( -
-
- - {entry.action} - - - {new Date(entry.ts).toLocaleString()} - -
-
- {formatDetail(entry.detail)} -
-
- {entry.ip} -
-
- )) - )} -
- - {/* Desktop table */} -
- - - - - - - - - - - {loading && entries.length === 0 ? ( - - - - ) : entries.length === 0 ? ( - - - - ) : ( - entries.map((entry, i) => ( - - - - - - - )) - )} - -
TimeActionDetailsIP
Loading...
No entries found
- {new Date(entry.ts).toLocaleString()} - - - {entry.action} - - - {formatDetail(entry.detail)} - - {entry.ip} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Page {page} of {totalPages} -

-
- - -
-
- )} -
- ); -} - -function formatDetail(detail: Record): string { - if (!detail || Object.keys(detail).length === 0) return '-'; - if (detail.reason) return String(detail.reason); - if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`; - if (detail.changes && Array.isArray(detail.changes)) { - return detail.changes.map((c: Record) => `${c.key}`).join(', '); - } - if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`; - return JSON.stringify(detail).slice(0, 100); +export default function Page() { + redirect('/admin?tab=logs'); } diff --git a/app/admin/marketplace/page.tsx b/app/admin/marketplace/page.tsx index f9befba3..ac089504 100644 --- a/app/admin/marketplace/page.tsx +++ b/app/admin/marketplace/page.tsx @@ -1,366 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useCallback } from 'react'; -import Link from 'next/link'; -import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface Extension { - slug: string; - name: string; - type: 'plugin' | 'theme'; - pluginType: string | null; - description: string; - permissions: string[]; - tags: string[]; - totalDownloads: number; - featured: boolean; - minAppVersion: string | null; - latestVersion: string | null; - installed: boolean; - author: { - displayName: string; - githubLogin: string; - avatarUrl: string | null; - } | null; -} - -interface SearchResult { - data: Extension[]; - meta: { - page: number; - perPage: number; - total: number; - }; -} - -type TypeFilter = 'all' | 'plugin' | 'theme'; - -export default function AdminMarketplacePage() { - const [extensions, setExtensions] = useState([]); - const [loading, setLoading] = useState(true); - const [query, setQuery] = useState(''); - const [typeFilter, setTypeFilter] = useState('all'); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [perPage] = useState(12); - const [installing, setInstalling] = useState(null); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const [error, setError] = useState(null); - - const fetchExtensions = useCallback(async () => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams(); - if (query) params.set('q', query); - if (typeFilter !== 'all') params.set('type', typeFilter); - params.set('page', String(page)); - params.set('perPage', String(perPage)); - params.set('sort', 'newest'); - - const res = await apiFetch(`/api/admin/marketplace?${params}`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setError(data.error || 'Failed to connect to extension directory'); - setExtensions([]); - return; - } - - const data: SearchResult = await res.json(); - setExtensions(data.data || []); - setTotal(data.meta?.total || 0); - } catch { - setError('Failed to connect to extension directory. Make sure it is running.'); - setExtensions([]); - } finally { - setLoading(false); - } - }, [query, typeFilter, page, perPage]); - - useEffect(() => { - fetchExtensions(); - }, [fetchExtensions]); - - // Debounced search - const [searchInput, setSearchInput] = useState(''); - useEffect(() => { - const t = setTimeout(() => { - setQuery(searchInput); - setPage(1); - }, 300); - return () => clearTimeout(t); - }, [searchInput]); - - async function handleInstall(ext: Extension) { - setInstalling(ext.slug); - setMessage(null); - - try { - const res = await apiFetch('/api/admin/marketplace', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - slug: ext.slug, - version: ext.latestVersion || '1.0.0', - type: ext.type, - }), - }); - - const data = await res.json(); - - if (res.ok) { - const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; - setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); - // Mark as installed in the UI - setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); - } else { - setMessage({ type: 'error', text: data.error || 'Installation failed' }); - } - } catch { - setMessage({ type: 'error', text: 'Installation failed - network error' }); - } finally { - setInstalling(null); - } - } - - const totalPages = Math.ceil(total / perPage); - - return ( -
-
-

Marketplace

-

- Browse and install plugins and themes from the BulwarkMail extension directory -

-
- - {message && ( -
- {message.text} -
- )} - - {/* Search & Filters */} -
-
- - setSearchInput(e.target.value)} - className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" - /> -
-
- {(['all', 'plugin', 'theme'] as const).map((t) => ( - - ))} -
-
- - {/* Error State */} - {error && ( -
- -

{error}

-

- Start the extension directory server on the configured port -

- -
- )} - - {/* Loading State */} - {loading && !error && ( -
- - Searching extensions... -
- )} - - {/* Empty State */} - {!loading && !error && extensions.length === 0 && ( -
- -

No extensions found

- {query && ( -

- Try a different search term -

- )} -
- )} - - {/* Extension Grid */} - {!loading && !error && extensions.length > 0 && ( - <> -
- {total} extension{total !== 1 ? 's' : ''} found -
-
- {extensions.map((ext) => ( - handleInstall(ext)} - /> - ))} -
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {page} of {totalPages} - - -
- )} - - )} -
- ); -} - -function ExtensionCard({ - extension, - installing, - onInstall, -}: { - extension: Extension; - installing: boolean; - onInstall: () => void; -}) { - const isPlugin = extension.type === 'plugin'; - const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; - - return ( -
- - {/* Header */} -
-
- {isPlugin ? ( - - ) : ( - - )} -
-
-
- - {extension.name} - - {extension.featured && ( - - )} -
-
- - {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} - - {extension.author && ( - - by {extension.author.displayName} - - )} -
-
-
- - {/* Description */} -

- {extension.description} -

- - {/* Tags */} - {extension.tags && extension.tags.length > 0 && ( -
- {extension.tags.slice(0, 3).map(tag => ( - - {tag} - - ))} -
- )} - - {/* Footer (download count + permissions) */} -
-
- - - {extension.totalDownloads.toLocaleString()} - - {extension.permissions && extension.permissions.length > 0 && ( - - {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} - - )} -
- - - Preview - -
- - - {/* Quick install button (sits over the link, stops navigation) */} -
- {extension.installed ? ( - - - Installed - - ) : ( - - )} -
-
- ); +export default function Page() { + redirect('/admin?tab=marketplace'); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 98eb9c81..71562e54 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,230 +1,49 @@ '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'; +import { useEffect } from 'react'; +import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store'; +import { DashboardTab } from './_tabs/dashboard'; +import { SettingsTab } from './_tabs/settings'; +import { BrandingTab } from './_tabs/branding'; +import { AuthTab } from './_tabs/auth'; +import { PolicyTab } from './_tabs/policy'; +import { PluginsTab } from './_tabs/plugins'; +import { ThemesTab } from './_tabs/themes'; +import { MarketplaceTab } from './_tabs/marketplace'; +import { VersionTab } from './_tabs/version'; +import { TelemetryTab } from './_tabs/telemetry'; +import { LogsTab } from './_tabs/logs'; -interface AdminStatus { - enabled: boolean; - authenticated: boolean; - lastLogin: string | null; - passwordChangedAt: string | null; -} - -interface ConfigData { - appName?: string; - jmapServerUrl?: string; - settingsSyncEnabled?: boolean; - stalwartFeaturesEnabled?: boolean; - oauthEnabled?: boolean; - devMode?: boolean; -} - -export default function AdminDashboardPage() { - const [status, setStatus] = useState(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'); +export default function AdminPage() { + const activeTab = useAdminTabStore((s) => s.activeTab); + const setActiveTab = useAdminTabStore((s) => s.setActiveTab); + // Honour deep links from the old route structure: /admin?tab=settings + // (emitted by the redirect pages in /admin//page.tsx) sets the store + // once on mount, then strips the param so the URL stays at /admin and + // subsequent tab clicks don't accumulate query strings. useEffect(() => { - fetchDashboardData(); - }, []); + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + const fromUrl = url.searchParams.get('tab'); + if (isAdminTab(fromUrl)) { + setActiveTab(fromUrl); + url.searchParams.delete('tab'); + window.history.replaceState(null, '', url.pathname + url.search + url.hash); + } + }, [setActiveTab]); - 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); + switch (activeTab) { + case 'dashboard': return ; + case 'settings': return ; + case 'branding': return ; + case 'auth': return ; + case 'policy': return ; + case 'plugins': return ; + case 'themes': return ; + case 'marketplace': return ; + case 'version': return ; + case 'telemetry': return ; + case 'logs': return ; } - - const jmapUrl = config?.jmapServerUrl || '-'; - const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-'; - - return ( -
- {/* Warnings */} - {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. -

-
-
- )} - - {/* Server Info */} - - - {config?.appName || '-'} - - - {jmapHostname} - - - - - {jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'} - - - - - {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} - - - - - {/* Features */} - - - {}} disabled /> - - - {}} disabled /> - - - {}} disabled /> - - - {}} disabled /> - - - - {/* Accounts */} - - - {accountCounts?.total ?? '-'} - - - {accountCounts?.active7d ?? '-'} - - - - {/* Extensions */} - - - {pluginCount} - - - {themeCount} - - - {policyRuleCount} - - - - {/* Recent Activity */} - - {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/plugins/page.tsx b/app/admin/plugins/page.tsx index 63f076bd..804485a2 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -1,468 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -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; - /** True when loaded from PLUGIN_DEV_DIR (read-only, managed via filesystem) */ - dev?: boolean; -} - -export default function AdminPluginsPage() { - 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); - // If force-enabling, also ensure the plugin is enabled - 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)); - // Also update policy - 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 */} -
-
-
- -

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

-
- -
- - {/* Force enable / disable all */} - {plugins.length > 0 && ( -
-
- Force Enable / Disable All -

Bulk toggle all deployed plugins at once

-
-
- - -
-
- )} -
-
- - {/* Deployed Plugins */} -
-
-
- -

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.dev && ( - - Dev - - )} - {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(', ')} - -
- )} -
- -
- - - - - - -
-
- ))} -
- )} -
-
- ); +export default function Page() { + redirect('/admin?tab=plugins'); } diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index 9eb0588b..9d33304c 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -1,220 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -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'; - -// Feature gates managed on their own admin pages (excluded from this list) -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 default function AdminPolicyPage() { - 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 */} -
-
-

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}

-
- -
- ); - })} -
-
- - {/* Setting Restrictions */} - {categories.map(category => ( -
-
-

{category}

-
-
- {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { - const restriction = policy.restrictions[setting.key] || {}; - return ( -
- {setting.label} -
- - -
-
- ); - })} -
-
- ))} -
- ); +export default function Page() { + redirect('/admin?tab=policy'); } diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 1c9125e5..7a6e1020 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -1,248 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -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 default function AdminSettingsPage() { - 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} -
- )} - - {/* General */} - - - - - {!!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. -

-
- )} - - -
- - {/* Logging */} - - - - - - {/* Settings Sync */} - - - -
- ); -} - -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' && ( - - )} -
-
- ); +export default function Page() { + redirect('/admin?tab=settings'); } diff --git a/app/admin/telemetry/page.tsx b/app/admin/telemetry/page.tsx index 058df0d8..73343a1d 100644 --- a/app/admin/telemetry/page.tsx +++ b/app/admin/telemetry/page.tsx @@ -1,250 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface TelemetryStatus { - consent: 'pending' | 'on' | 'off'; - consentSource: 'env' | 'file'; - endpoint: string; - defaultEndpoint: string; - consentedAt: string | null; - lastSentAt: string | null; - nextScheduledAt: string | null; - payloadPreview: Record; - accountCounts: { total: number; active7d: number }; -} - -function timeAgo(iso: string | null): string { - if (!iso) return 'never'; - const d = Date.now() - new Date(iso).getTime(); - if (d < 0) return new Date(iso).toLocaleString(); - const m = Math.floor(d / 60000); - if (m < 1) return 'just now'; - if (m < 60) return `${m} min ago`; - const h = Math.floor(m / 60); - if (h < 48) return `${h} hours ago`; - const days = Math.floor(h / 24); - return `${days} days ago`; -} - -export default function AdminTelemetryPage() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(null); - const [endpointDraft, setEndpointDraft] = useState(''); - const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); - - async function refresh(): Promise { - setLoading(true); - try { - const r = await apiFetch('/api/admin/telemetry'); - if (!r.ok) throw new Error('failed to load'); - const data = (await r.json()) as TelemetryStatus; - setStatus(data); - setEndpointDraft(data.endpoint); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - } - useEffect(() => { void refresh(); }, []); - - async function setConsent(consent: 'on' | 'off'): Promise { - setBusy('consent'); - try { - const r = await apiFetch('/api/admin/telemetry', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action: 'set-consent', consent }), - }); - if (!r.ok) { - const j = (await r.json().catch(() => ({}))) as { error?: string }; - alert(j.error ?? 'failed'); - } - await refresh(); - } finally { setBusy(null); } - } - - async function saveEndpoint(): Promise { - setBusy('endpoint'); - try { - const r = await apiFetch('/api/admin/telemetry', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }), - }); - if (!r.ok) { - const j = (await r.json().catch(() => ({}))) as { error?: string }; - alert(j.error ?? 'failed'); - } - await refresh(); - } finally { setBusy(null); } - } - - async function sendNow(): Promise { - setBusy('send'); - setSendResult(null); - try { - const r = await apiFetch('/api/admin/telemetry', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action: 'send-now' }), - }); - const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string }; - setSendResult({ - ok: !!j.ok, - msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`, - }); - await refresh(); - } finally { setBusy(null); } - } - - if (loading || !status) { - return ( -
- loading… -
- ); - } - - const envOverridden = status.consentSource === 'env'; - const isOn = status.consent === 'on'; - - return ( -
-
-

Anonymous Usage Stats

-

- Bulwark sends one anonymous heartbeat per day so we can see how many instances are - running, on what platforms, and which features they use. Enabled by default; - one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} - - Full schema and policy - -

-
- -
-
-
-
Status
-
- {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} - {status.consent === 'on' && 'Heartbeats are enabled (default).'} - {status.consent === 'off' && 'Heartbeats are off.'} - {envOverridden && ( - <> Locked by BULWARK_TELEMETRY env var. - )} -
-
-
- - -
-
-
-
Last sent
-
{timeAgo(status.lastSentAt)}
-
Next scheduled
-
{timeAgo(status.nextScheduledAt)}
-
Consented at
-
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
-
-
- -
-
Account activity
-

- Unique accounts that have logged in over the last 90 days. Identities are stored as a - per-instance HMAC, never as plaintext usernames. These are the numbers reported in the - heartbeat as bucketed ranges. -

-
-
Total (90d)
-
{status.accountCounts?.total ?? 0}
-
Active (7d)
-
{status.accountCounts?.active7d ?? 0}
-
-
- -
-
Endpoint
-

- Where heartbeats are sent. Defaults to the project's collector. Point at your own collector - (open source at bulwarkmail/dashboard) or clear this field to disable sending. -

-
- setEndpointDraft(e.target.value)} - placeholder={status.defaultEndpoint} - className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background" - /> - -
-
- -
-
-
-
Payload preview
-
- Exactly what the next heartbeat would send from this install, right now. -
-
- -
- {sendResult && ( -
- {sendResult.ok ? : } - {sendResult.msg} -
- )} -
-          {JSON.stringify(status.payloadPreview, null, 2)}
-        
-
-
- ); +export default function Page() { + redirect('/admin?tab=telemetry'); } diff --git a/app/admin/themes/page.tsx b/app/admin/themes/page.tsx index c654edfc..3dc03d45 100644 --- a/app/admin/themes/page.tsx +++ b/app/admin/themes/page.tsx @@ -1,553 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useRef } from 'react'; -import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react'; -import type { SettingsPolicy } from '@/lib/admin/types'; -import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -const BUILTIN_THEME_OPTIONS = [ - { id: 'builtin-nord', name: 'Nord' }, - { id: 'builtin-catppuccin', name: 'Catppuccin' }, - { id: 'builtin-solarized', name: 'Solarized' }, -]; - -interface ThemeEntry { - id: string; - name: string; - version: string; - author: string; - description: string; - variants: string[]; - enabled: boolean; - forceEnabled?: boolean; - installedAt: string; - updatedAt: string; -} - -export default function AdminThemesPage() { - const [themes, setThemes] = 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(() => { fetchThemes(); fetchPolicy(); }, []); - - async function fetchPolicy() { - try { - const res = await apiFetch('/api/admin/policy'); - if (res.ok) { - const data = await res.json(); - setPolicy({ - ...data, - themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) }, - }); - } - } catch { /* ignore */ } - } - - function toggleThemesEnabled() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, themesEnabled: !prev.features.themesEnabled }, - })); - setPolicyDirty(true); - setMessage(null); - } - - function toggleUserThemeUploads() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled }, - })); - setPolicyDirty(true); - setMessage(null); - } - - function toggleBuiltinTheme(themeId: string) { - setPolicy(prev => { - const disabled = prev.themePolicy?.disabledBuiltinThemes || []; - const isDisabled = disabled.includes(themeId); - return { - ...prev, - themePolicy: { - ...DEFAULT_THEME_POLICY, - ...prev.themePolicy, - disabledBuiltinThemes: isDisabled - ? disabled.filter((id: string) => id !== themeId) - : [...disabled, themeId], - }, - }; - }); - setPolicyDirty(true); - setMessage(null); - } - - function toggleAdminTheme(themeId: string) { - setPolicy(prev => { - const disabled = prev.themePolicy?.disabledThemes || []; - const isDisabled = disabled.includes(themeId); - return { - ...prev, - themePolicy: { - ...DEFAULT_THEME_POLICY, - ...prev.themePolicy, - disabledThemes: isDisabled - ? disabled.filter((id: string) => id !== themeId) - : [...disabled, themeId], - }, - }; - }); - setPolicyDirty(true); - setMessage(null); - } - - function setDefaultTheme(themeId: string | null) { - setPolicy(prev => ({ - ...prev, - themePolicy: { - ...DEFAULT_THEME_POLICY, - ...prev.themePolicy, - defaultThemeId: themeId, - }, - })); - 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: 'Theme 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 fetchThemes() { - setLoading(true); - try { - const res = await apiFetch('/api/admin/themes'); - if (res.ok) setThemes(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/themes', { - 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: `Theme "${data.theme.name}" installed${warnings}` }); - await fetchThemes(); - } 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 toggleTheme(id: string, enabled: boolean) { - setMessage(null); - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, enabled }), - }); - - if (res.ok) { - setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t)); - } 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/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (res.ok) { - setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t)); - setPolicy(prev => { - const current = prev.forceEnabledThemes || []; - return { - ...prev, - forceEnabledThemes: forceEnabled - ? [...current.filter(tid => tid !== id), id] - : current.filter(tid => tid !== id), - }; - }); - setPolicyDirty(true); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Update failed' }); - } - } - - async function forceEnableAll() { - setMessage(null); - const disabled = themes.filter(t => !t.enabled); - if (disabled.length === 0) { - setMessage({ type: 'success', text: 'All themes are already enabled' }); - return; - } - let failed = 0; - for (const t of disabled) { - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: t.id, enabled: true }), - }); - if (!res.ok) failed++; - } - if (failed === 0) { - await fetchThemes(); - setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` }); - } else { - await fetchThemes(); - setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` }); - } - } - - async function forceDisableAll() { - setMessage(null); - const enabled = themes.filter(t => t.enabled); - if (enabled.length === 0) { - setMessage({ type: 'success', text: 'All themes are already disabled' }); - return; - } - let failed = 0; - for (const t of enabled) { - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: t.id, enabled: false }), - }); - if (!res.ok) failed++; - } - if (failed === 0) { - await fetchThemes(); - setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` }); - } else { - await fetchThemes(); - setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` }); - } - } - - async function deleteTheme(id: string, name: string) { - if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return; - - setMessage(null); - const res = await apiFetch('/api/admin/themes', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - - if (res.ok) { - setThemes(prev => prev.filter(t => t.id !== id)); - setMessage({ type: 'success', text: `Theme "${name}" removed` }); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Delete failed' }); - } - } - - if (loading) { - return
Loading...
; - } - - const themesEnabled = policy.features.themesEnabled ?? true; - const userThemesEnabled = policy.features.userThemesEnabled ?? true; - - return ( -
-
-
-

Themes

-

Manage themes and theme policy for all users

-
-
- {policyDirty && ( - - )} - -
-
- - {message && ( -
- {message.text} -
- )} - - {/* Theme Policy */} -
-
-
- -

Theme Policy

-
-

Control theme availability and defaults for users

-
- -
- {/* Master toggle */} -
-
- Themes Enabled -

Allow users to select and apply themes

-
- -
- - {/* User uploads toggle */} -
-
- User Theme Uploads -

Allow users to upload their own theme files

-
- -
- - {/* Force enable / disable all */} - {themes.length > 0 && ( -
-
- Force Enable / Disable All -

Bulk toggle all deployed themes at once

-
-
- - -
-
- )} - - {/* Default Theme */} -
-
-
- Default Theme -

Theme applied when users have not chosen one

-
- -
-
- - {/* Built-in themes */} -
- Built-in Themes -
- {BUILTIN_THEME_OPTIONS.map(theme => { - const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id); - return ( -
- {theme.name} - -
- ); - })} -
-
- - {/* Admin-deployed themes */} - {themes.length > 0 && ( -
- Admin-deployed Themes -
- {themes.map(theme => { - const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id); - return ( -
- {theme.name} - -
- ); - })} -
-
- )} -
-
- - {/* Deployed Themes */} -
-
-
- -

Deployed Themes

-
-

Admin-uploaded themes available to all users

-
- {themes.length === 0 ? ( -
- -

No themes installed

-

Upload a theme ZIP file to get started

-
- ) : ( -
- {themes.map(theme => ( -
-
-
- {theme.name} - v{theme.version} - - {theme.enabled ? 'Enabled' : 'Disabled'} - - {theme.forceEnabled && ( - - Forced - - )} -
- {theme.description && ( -

{theme.description}

- )} -
- by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()} -
-
- -
- - - -
-
- ))} -
- )} -
-
- ); +export default function Page() { + redirect('/admin?tab=themes'); } diff --git a/app/admin/version/page.tsx b/app/admin/version/page.tsx index 398aba28..893b1dfe 100644 --- a/app/admin/version/page.tsx +++ b/app/admin/version/page.tsx @@ -1,237 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { - Loader2, - RefreshCw, - CheckCircle2, - AlertTriangle, - ShieldAlert, - ExternalLink, -} from 'lucide-react'; -import { SettingsSection, SettingItem } from '@/components/settings/settings-section'; -import { apiFetch } from '@/lib/browser-navigation'; -import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; - -interface VersionAdminStatus { - current: string; - build: string; - endpoint: string; - defaultEndpoint: string; - disabledByEnv: boolean; - lastCheckedAt: string | null; - lastSuccessAt: string | null; - nextScheduledAt: string | null; - status: UpdateStatus | null; -} - -function timeAgo(iso: string | null): string { - if (!iso) return 'never'; - const d = Date.now() - new Date(iso).getTime(); - if (d < 0) return new Date(iso).toLocaleString(); - const m = Math.floor(d / 60000); - if (m < 1) return 'just now'; - if (m < 60) return `${m} min ago`; - const h = Math.floor(m / 60); - if (h < 48) return `${h} hours ago`; - return `${Math.floor(h / 24)} days ago`; -} - -function severityChip(severity: UpdateSeverity) { - switch (severity) { - case 'security': - return { - label: 'Security update', - className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', - Icon: ShieldAlert, - }; - case 'deprecated': - return { - label: 'Deprecated', - className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', - Icon: ShieldAlert, - }; - case 'normal': - return { - label: 'Update available', - className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30', - Icon: AlertTriangle, - }; - case 'unknown': - return { - label: 'Unknown', - className: 'bg-muted text-muted-foreground border-border', - Icon: AlertTriangle, - }; - case 'none': - default: - return { - label: 'Up to date', - className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30', - Icon: CheckCircle2, - }; - } -} - -export default function AdminVersionPage() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [checking, setChecking] = useState(false); - const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null); - - async function refresh(): Promise { - setLoading(true); - try { - const r = await apiFetch('/api/admin/version'); - if (!r.ok) throw new Error('failed to load'); - setData((await r.json()) as VersionAdminStatus); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - } - useEffect(() => { void refresh(); }, []); - - async function checkNow(): Promise { - setChecking(true); - setCheckResult(null); - try { - const r = await apiFetch('/api/admin/version', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ action: 'check-now' }), - }); - const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string }; - setCheckResult({ - ok: !!j.ok, - msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`, - }); - await refresh(); - } finally { - setChecking(false); - } - } - - if (loading || !data) { - return ( -
- loading… -
- ); - } - - const status = data.status; - const chip = severityChip(status?.severity ?? 'none'); - const ChipIcon = chip.Icon; - const releaseUrl = status?.url ?? null; - const newer = status?.latest && status.latest !== data.current ? status.latest : null; - - return ( -
-
-
-

Version

-

- Hourly check against the Bulwark version server. Severity is decided server-side and - disable with BULWARK_UPDATE_CHECK=off. -

-
- -
- - {checkResult && ( -
- {checkResult.msg} -
- )} - - - - - - {chip.label} - - - - {data.current} - - {newer && ( - - {releaseUrl ? ( - - {newer} - - ) : ( - {newer} - )} - - )} - {status?.advisory && ( - - {status.advisory} - - )} - - - - - {timeAgo(data.lastCheckedAt)} - - - {timeAgo(data.lastSuccessAt)} - - - {timeAgo(data.nextScheduledAt)} - - {status?.checkedAt && ( - - {new Date(status.checkedAt).toLocaleString()} - - )} - - - - - - {data.endpoint} - - - - - {data.disabledByEnv ? 'Yes' : 'No'} - - - -
- ); +export default function Page() { + redirect('/admin?tab=version'); } diff --git a/stores/admin-tab-store.ts b/stores/admin-tab-store.ts new file mode 100644 index 00000000..c9b77622 --- /dev/null +++ b/stores/admin-tab-store.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export const ADMIN_TABS = [ + 'dashboard', + 'settings', + 'branding', + 'auth', + 'policy', + 'plugins', + 'themes', + 'marketplace', + 'version', + 'telemetry', + 'logs', +] as const; + +export type AdminTabId = typeof ADMIN_TABS[number]; + +export function isAdminTab(value: string | null | undefined): value is AdminTabId { + return typeof value === 'string' && (ADMIN_TABS as readonly string[]).includes(value); +} + +interface AdminTabState { + activeTab: AdminTabId; + setActiveTab: (tab: AdminTabId) => void; +} + +// Tab state lives in client memory + localStorage. Sidebar clicks update +// state (no URL navigation) so React can commit the transition immediately, +// avoiding the dev-mode "Rendering…" hang we saw when each tab was its own +// route or distinguished by ?tab= search param. +export const useAdminTabStore = create()( + persist( + (set) => ({ + activeTab: 'dashboard', + setActiveTab: (tab) => set({ activeTab: tab }), + }), + { name: 'admin_active_tab' }, + ), +);