diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx new file mode 100644 index 000000000..120e5b6a6 --- /dev/null +++ b/app/(main)/admin/[[...segments]]/page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAdminLocation, navigateAdmin } from '@/hooks/use-admin-navigation'; +import { adminPath, isAdminPageId } from '@/lib/admin/admin-nav'; +import { DashboardPage } from '../_pages/dashboard'; +import { SettingsPage } from '../_pages/settings'; +import { BrandingPage } from '../_pages/branding'; +import { AuthPage } from '../_pages/auth'; +import { PolicyPage } from '../_pages/policy'; +import { PluginsPage } from '../_pages/plugins'; +import { ThemesPage } from '../_pages/themes'; +import { MarketplacePage } from '../_pages/marketplace'; +import { MarketplaceDetailPage } from '../_pages/marketplace-detail'; +import { ExtensionDetailPage } from '../_pages/extension-detail'; +import { VersionPage } from '../_pages/version'; +import { TelemetryPage } from '../_pages/telemetry'; +import { LogsPage } from '../_pages/logs'; + +/** + * Every admin page, picked from the address bar. One route for the whole + * panel: the sidebar switches pages with history.pushState and this + * component re-renders from `usePathname()`, so there is never a route + * transition between pages (lib/admin/admin-nav.ts). + */ +export default function AdminPage() { + const { page, detail } = useAdminLocation(); + + // Links from before the pages had URLs: /admin?tab=settings. + useEffect(() => { + const url = new URL(window.location.href); + const tab = url.searchParams.get('tab'); + if (!isAdminPageId(tab)) return; + url.searchParams.delete('tab'); + navigateAdmin(`${adminPath(tab)}${url.search}${url.hash}`, { replace: true }); + }, []); + + switch (page ?? 'dashboard') { + case 'dashboard': return ; + case 'settings': return ; + case 'branding': return ; + case 'auth': return ; + case 'policy': return ; + case 'plugins': return detail ? : ; + case 'themes': return detail ? : ; + case 'marketplace': return detail ? : ; + case 'version': return ; + case 'telemetry': return ; + case 'logs': return ; + } +} diff --git a/app/(main)/admin/__tests__/marketplace-install-version.test.tsx b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx index 2c8f628c0..e33885cc5 100644 --- a/app/(main)/admin/__tests__/marketplace-install-version.test.tsx +++ b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx @@ -14,7 +14,7 @@ vi.mock('@/lib/browser-navigation', () => ({ })); vi.mock('next/link', () => ({ default: () => null })); -import { installVersion } from '../_tabs/marketplace'; +import { installVersion } from '../_pages/marketplace'; const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); const BODY = { slug: 'jitsi-meet', version: '2.0.9', type: 'plugin' as const }; diff --git a/app/(main)/admin/_layout-client.tsx b/app/(main)/admin/_layout-client.tsx new file mode 100644 index 000000000..1a91ca9ac --- /dev/null +++ b/app/(main)/admin/_layout-client.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; +import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; +import { AdminShell } from '@/components/admin/admin-shell'; +import { UnsavedChangesGuard } from './_unsaved-changes-guard'; + +/** + * Gates the admin panel behind the admin session and draws its shell. The + * pages themselves are one route (./[[...segments]]) and switch through + * history.pushState, so this layout stays mounted across them. + */ +export function AdminLayoutClient({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); + const [authenticated, setAuthenticated] = useState(null); + const [authError, setAuthError] = useState(null); + const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false); + const [configReadOnly, setConfigReadOnly] = useState(false); + + useEffect(() => { + if (pathname === '/admin/login') return; + // One check per visit, not one per page: the pages switch without a + // route change, and the session does not change with them. + if (authenticated === true) return; + let cancelled = false; + + async function checkAuth() { + try { + const jmapHeaders = getActiveAccountSlotHeaders(); + const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders }); + const data = await res.json(); + if (cancelled) return; + + // `stalwartAutoLogin` is only true in "auto" mode; in "password" mode + // a Stalwart admin still lands on the password form (#870). + const stalwartAutoLogin = data.stalwartAutoLogin === true; + setIsStalwartAutoLogin(stalwartAutoLogin); + + // If neither password-based admin nor Stalwart auto-login, redirect away + if (!data.enabled && !stalwartAutoLogin) { + router.replace('/'); + return; + } + + if (data.authenticated) { + setAuthenticated(true); + return; + } + + // If Stalwart admin (auto mode) but not yet authenticated, auto-login + if (stalwartAutoLogin) { + const loginRes = await apiFetch('/api/admin/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...jmapHeaders }, + body: JSON.stringify({ stalwartAuth: true }), + }); + if (cancelled) return; + if (loginRes.ok) { + setAuthenticated(true); + return; + } + const body = await loginRes.json().catch(() => ({})); + setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`); + setAuthenticated(false); + return; + } + + // Carry the page that was asked for through the login, so a connector + // link to a panel (/connector/admin_extension?slug=x) does not dump + // the admin on the overview after signing in. + const next = encodeURIComponent(`${pathname}${window.location.search}`); + router.replace(`/admin/login?next=${next}`); + } catch (err) { + if (cancelled) return; + setAuthError(err instanceof Error ? err.message : 'Network error during admin check'); + setAuthenticated(false); + } + } + + checkAuth(); + return () => { cancelled = true; }; + }, [pathname, router, authenticated]); + + // With ADMIN_CONFIG_READONLY the panel still shows every setting, but + // nothing can be saved; say so up front instead of after a failed save (#1249). + useEffect(() => { + if (!authenticated) return; + let cancelled = false; + apiFetch('/api/setup/status') + .then(res => (res.ok ? res.json() : null)) + .then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [authenticated]); + + function handleLogout() { + requestLeave(async () => { + await apiFetch('/api/admin/auth', { method: 'DELETE' }); + router.replace('/admin/login'); + }); + } + + // Don't gate the login page + if (pathname === '/admin/login') { + return <>{children}; + } + + return ( + <> + + + {authError ? ( +
+

Admin authentication failed

+

{authError}

+
+ ) : authenticated === null ? ( +
+ Loading admin panel… +
+ ) : authenticated ? children : null} +
+ + ); +} diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_pages/_jmap-servers-section.tsx similarity index 68% rename from app/(main)/admin/_tabs/_jmap-servers-section.tsx rename to app/(main)/admin/_pages/_jmap-servers-section.tsx index 71e05540a..0e6637b97 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_pages/_jmap-servers-section.tsx @@ -1,8 +1,11 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from '@/components/icons'; +import { Plus, Trash2, ChevronDown, ChevronRight } from '@/components/icons'; import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; +import { Button } from '@/components/ui/button'; +import { inputClass, RevertButton, SourceBadge } from '@/components/admin/config-items'; +import { cn } from '@/lib/utils'; interface Props { value: JmapServerEntry[]; @@ -93,6 +96,9 @@ function emptyDraft(): RowDraft { }; } +const fieldLabelClass = 'block text-[11px] font-medium text-muted-foreground mb-1'; +const fieldClass = cn(inputClass, 'w-full'); + export function JmapServersSection({ value, source, onChange, onRevert }: Props) { const [drafts, setDrafts] = useState(() => value.map(entryToDraft)); const lastEmittedRef = useRef(value); @@ -132,38 +138,21 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props) }); return ( -
-
-
-
- Servers - {source && source !== 'default' && ( - - {source} - - )} -
-

- Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single JMAP Server URL above. +

+
+
+ Servers +

+ Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single JMAP server URL above.

-
- {source === 'admin' && ( - - )} - +
@@ -177,40 +166,42 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
- + update(i, { id: e.target.value })} placeholder="main" - className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`} + className={cn(fieldClass, isDuplicate && 'border-destructive')} /> {isDuplicate &&

Duplicate id

}
- + update(i, { label: e.target.value })} placeholder="Main server" - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={fieldClass} />
- +
update(i, { url: e.target.value })} placeholder="https://mail.example.com" - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={fieldClass} /> @@ -218,7 +209,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
-
+ +
+
+
+ )} + +
+ + + + +
+ + {configReadOnly && ( +

+ Not available while the configuration is read-only (ADMIN_CONFIG_READONLY). +

+ )} +
+
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + setEdits({})} + note="Takes effect on the next page load" + /> +
+ ); +} diff --git a/app/(main)/admin/_pages/branding.tsx b/app/(main)/admin/_pages/branding.tsx new file mode 100644 index 000000000..a4c1318e0 --- /dev/null +++ b/app/(main)/admin/_pages/branding.tsx @@ -0,0 +1,639 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Loader2, ImageIcon, Upload, Trash2, Plus, X } from '@/components/icons'; +import { apiFetch, withBasePath } from '@/lib/browser-navigation'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { + BRANDING_OVERRIDE_KEYS, + parseDomainBranding, + type BrandingOverrideKey, + type DomainBrandingEntry, +} from '@/lib/admin/domain-branding'; +import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Badge } from '@/components/admin/badge'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { inputClass, RevertButton } from '@/components/admin/config-items'; +import { cn } from '@/lib/utils'; + +interface ConfigEntry { + value?: unknown; + source: 'admin' | 'env' | 'default'; + hasValue?: boolean; +} + +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' }, +] as const; + +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' }, +] as const; + +const PWA_IMAGE_FIELDS = [ + { key: 'pwaIconUrl', label: 'Icon', accept: '.svg,.png,.jpg,.webp' }, + { key: 'pwaScreenshotMobileUrl', label: 'Screenshot (mobile)', accept: '.png,.jpg,.webp' }, + { key: 'pwaScreenshotDesktopUrl', label: 'Screenshot (desktop)', accept: '.png,.jpg,.webp' }, +] as const; + +const PWA_TEXT_FIELDS = [ + { key: 'appShortName', label: 'Short name', placeholder: 'Shown on home screen (max ~12 chars)' }, + { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, +] as const; + +const PWA_COLOR_FIELDS = [ + { key: 'pwaThemeColor', label: 'Theme colour', defaultValue: '#ffffff' }, + { key: 'pwaBackgroundColor', label: 'Background colour', defaultValue: '#ffffff' }, +] as const; + +// Accepts exact hosts and one-level wildcards (e.g. *.example.com). +const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; +// Tighter rule for uploads: wildcards can only point to externally-hosted +// URLs, since we'd have no concrete subdomain to serve a file from. +const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; + +const PAGE = adminPageDef('branding'); + +const chipClass = (selected: boolean) => cn( + 'h-8 px-3 rounded-md text-sm font-medium transition-colors', + selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground', +); + +export function BrandingPage() { + 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] = useFlash(); + const [selectedHost, setSelectedHost] = useState(null); + const [addingHost, setAddingHost] = useState(false); + const [newHostInput, setNewHostInput] = useState(''); + const [newHostError, setNewHostError] = useState(null); + + useEffect(() => { + fetchConfig(); + }, []); + + const domainEntries = useMemo( + () => parseDomainBranding(config['domainBranding']?.value), + [config], + ); + + // Drop selection if the host disappeared from the config (e.g. concurrent edit). + useEffect(() => { + if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) { + setSelectedHost(null); + setEdits({}); + } + }, [domainEntries, selectedHost]); + + async function fetchConfig() { + setLoading(true); + const res = await apiFetch('/api/admin/config'); + if (res.ok) setConfig(await res.json()); + setLoading(false); + } + + function selectedEntry(): DomainBrandingEntry | null { + if (!selectedHost) return null; + return domainEntries.find(e => e.host === selectedHost) ?? null; + } + + 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]; + if (selectedHost) { + const entry = selectedEntry(); + return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? ''; + } + return (config[key]?.value as string) ?? ''; + } + + function isOverriddenInScope(key: string): boolean { + if (selectedHost) { + const entry = selectedEntry(); + const v = entry?.[key as BrandingOverrideKey]; + return typeof v === 'string' && v.length > 0; + } + return config[key]?.source === 'admin'; + } + + const isUploadedFile = (key: string): boolean => { + const val = currentValue(key); + return val.startsWith('/api/admin/branding/'); + }; + + function buildUpdatedDomainBranding(merge: Record): DomainBrandingEntry[] { + if (!selectedHost) return domainEntries; + const next = domainEntries.slice(); + const idx = next.findIndex(e => e.host === selectedHost); + const base: DomainBrandingEntry = + idx === -1 ? { host: selectedHost } : { ...next[idx] }; + const writable = base as unknown as Record; + for (const [key, value] of Object.entries(merge)) { + if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue; + if (typeof value === 'string' && value.length > 0) { + writable[key] = value; + } else { + delete writable[key]; + } + } + if (idx === -1) next.push(base); + else next[idx] = base; + return next; + } + + async function handleSave() { + if (Object.keys(edits).length === 0) return; + setSaving(true); + setMessage(null); + + const payload = selectedHost + ? { domainBranding: buildUpdatedDomainBranding(edits) } + : edits; + + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + setMessage({ + type: 'success', + text: selectedHost + ? `Branding for ${selectedHost} updated. Changes visible on next page load.` + : '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) { + if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) { + setMessage({ + type: 'error', + text: 'Wildcard hosts cannot upload files. Enter a URL instead.', + }); + return; + } + setUploading(slot); + setMessage(null); + + const formData = new FormData(); + formData.append('file', file); + formData.append('slot', slot); + if (selectedHost) formData.append('host', selectedHost); + + const res = await apiFetch('/api/admin/branding', { + method: 'POST', + body: formData, + }); + + if (res.ok) { + const data = await res.json(); + setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` }); + setEdits(prev => { + const next = { ...prev }; + delete next[slot]; + return next; + }); + // Refresh from server so domainBranding entries reflect the upload. + await fetchConfig(); + void data; + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Upload failed' }); + } + setUploading(null); + } + + async function handleDeleteUpload(slot: string) { + setMessage(null); + + const body: { slot: string; host?: string } = { slot }; + if (selectedHost) body.host = selectedHost; + + const res = await apiFetch('/api/admin/branding', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + 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) { + if (selectedHost) { + // Domain scope: drop the field from the entry and PATCH the array. + const updated = buildUpdatedDomainBranding({ [key]: '' }); + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: updated }), + }); + if (res.ok) { + setEdits(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + await fetchConfig(); + } + return; + } + // Default scope: revert via DELETE /api/admin/config + 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(); + } + } + + async function handleAddDomain() { + const host = newHostInput.trim().toLowerCase().replace(/\.+$/, ''); + if (!host) { + setNewHostError('Enter a hostname'); + return; + } + if (!HOST_RE.test(host)) { + setNewHostError('Invalid hostname. Use foo.example.com or *.example.com'); + return; + } + if (domainEntries.some(e => e.host === host)) { + setNewHostError('A branding entry for this host already exists'); + return; + } + setNewHostError(null); + + const next: DomainBrandingEntry[] = [...domainEntries, { host }]; + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: next }), + }); + if (res.ok) { + setNewHostInput(''); + setAddingHost(false); + setSelectedHost(host); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setNewHostError(data.error || 'Failed to add domain'); + } + } + + async function handleDeleteDomain() { + if (!selectedHost) return; + if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) { + return; + } + const next = domainEntries.filter(e => e.host !== selectedHost); + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: next }), + }); + if (res.ok) { + setSelectedHost(null); + setEdits({}); + await fetchConfig(); + setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` }); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to remove domain' }); + } + } + + function handleScopeChange(host: string | null) { + if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return; + setSelectedHost(host); + setEdits({}); + setMessage(null); + } + + const hasEdits = Object.keys(edits).length > 0; + const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); + + useAdminUnsavedChanges('branding', hasEdits); + + if (loading) { + return ( +
+ +
+ Loading +
+
+ ); + } + + /** The badge next to a field that is set in the scope being edited. */ + const overrideBadge = (key: string) => ( + isOverriddenInScope(key) + ? {isUploadedFile(key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} + : null + ); + + const imageRow = (field: { key: string; label: string; accept: string }) => ( + handleChange(field.key, value)} + onUpload={(file) => handleUpload(field.key, file)} + onDeleteUpload={() => handleDeleteUpload(field.key)} + onRevert={() => handleRevert(field.key)} + /> + ); + + const textRow = (field: { key: string; label: string }, placeholder: string) => ( + +
+ {overrideBadge(field.key)} + handleChange(field.key, e.target.value)} + placeholder={placeholder} + aria-label={field.label} + autoComplete="off" + spellCheck={false} + className={cn(inputClass, 'w-full min-w-0 sm:w-72')} + /> + {isOverriddenInScope(field.key) && handleRevert(field.key)} />} +
+
+ ); + + return ( +
+ + + setMessage(null)} /> + +
+ + + +
+ + {domainEntries.map(entry => ( + + ))} + {!addingHost && ( + + )} +
+ {addingHost && ( +
+ { setNewHostInput(e.target.value); setNewHostError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }} + placeholder="mail.example.com or *.example.com" + aria-label="Hostname" + autoComplete="off" + spellCheck={false} + className={cn(inputClass, 'w-64')} + /> + + + {newHostError && {newHostError}} +
+ )} + {selectedHost ? ( +
+

+ Editing overrides for {selectedHost}. + Unset fields fall back to the Default values. + {wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'} +

+ +
+ ) : ( +

+ Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname. +

+ )} +
+ + + {IMAGE_FIELDS.map(imageRow)} + + + + {PWA_IMAGE_FIELDS.map(imageRow)} + {PWA_TEXT_FIELDS.map(field => textRow(field, field.placeholder))} + {PWA_COLOR_FIELDS.map(field => { + const value = currentValue(field.key) || field.defaultValue; + return ( + +
+ {overrideBadge(field.key)} + handleChange(field.key, e.target.value)} + aria-label={`${field.label} picker`} + className="h-9 w-10 shrink-0 cursor-pointer rounded-md border border-input bg-background p-0.5" + title="Pick a color" + /> + handleChange(field.key, e.target.value)} + placeholder={field.defaultValue} + aria-label={field.label} + autoComplete="off" + spellCheck={false} + className={cn(inputClass, 'w-full min-w-0 font-mono sm:w-32')} + /> + {isOverriddenInScope(field.key) && handleRevert(field.key)} />} +
+
+ ); + })} +
+ + + {TEXT_FIELDS.map(field => textRow(field, field.key.includes('Url') ? 'https://...' : 'Enter value'))} + +
+ + setEdits({})} + note="Takes effect on the next page load" + /> +
+ ); +} + +interface ImageItemProps { + label: string; + accept: string; + value: string; + badge: ReactNode; + placeholder: string; + uploading: boolean; + uploadDisabled: boolean; + uploaded: boolean; + overridden: boolean; + onChange: (value: string) => void; + onUpload: (file: File) => void; + onDeleteUpload: () => void; + onRevert: () => void; +} + +/** One image slot: a URL field, an upload button, and a preview of what is set. */ +function ImageItem({ label, accept, value, badge, placeholder, uploading, uploadDisabled, uploaded, overridden, onChange, onUpload, onDeleteUpload, onRevert }: ImageItemProps) { + const fileInputRef = useRef(null); + return ( + +
+
+ {badge} + onChange(e.target.value)} + placeholder={placeholder} + aria-label={label} + autoComplete="off" + spellCheck={false} + className={cn(inputClass, 'w-full min-w-0 sm:w-64')} + /> + { + const file = e.target.files?.[0]; + if (file) onUpload(file); + e.target.value = ''; + }} + /> + + {uploaded && ( + + )} + {overridden && !uploaded && } +
+ {value && ( +
+ +
+ {label} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ )} +
+
+ ); +} diff --git a/app/(main)/admin/_pages/dashboard.tsx b/app/(main)/admin/_pages/dashboard.tsx new file mode 100644 index 000000000..6f4767212 --- /dev/null +++ b/app/(main)/admin/_pages/dashboard.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; +import { AlertTriangle } from '@/components/icons'; +import { SettingsSection, SettingItem } from '@/components/settings/settings-section'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminLink } from '@/components/admin/admin-link'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { Badge } from '@/components/admin/badge'; +import type { AuditEntry } from '@/lib/admin/types'; +import { adminPageDef, adminPath } from '@/lib/admin/admin-nav'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { UpdateStatus } from '@/lib/version-check/types'; +import { cn } from '@/lib/utils'; + +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; +} + +interface VersionData { + current: string; + status: UpdateStatus | null; +} + +const PAGE = adminPageDef('dashboard'); + +export function DashboardPage() { + 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 [pendingApprovals, setPendingApprovals] = useState(0); + const [themeCount, setThemeCount] = useState(0); + const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null); + const [version, setVersion] = useState(null); + + useEffect(() => { + fetchDashboardData(); + }, []); + + async function fetchDashboardData() { + const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, approvalsRes, themeRes, telemetryRes, versionRes] = 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/plugin-approvals').catch(() => null), + apiFetch('/api/admin/themes').catch(() => null), + apiFetch('/api/admin/telemetry').catch(() => null), + apiFetch('/api/admin/version').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 (approvalsRes?.ok) { + const data = await approvalsRes.json(); + const entries: { status?: string }[] = Array.isArray(data?.entries) ? data.entries : []; + setPendingApprovals(entries.filter((e) => e.status === 'pending').length); + } + if (themeRes?.ok) { + const themes = await themeRes.json(); + setThemeCount(Array.isArray(themes) ? themes.length : 0); + } + if (telemetryRes?.ok) { + const telemetry = await telemetryRes.json(); + if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') { + setAccountCounts(telemetry.accountCounts); + } + } + if (versionRes?.ok) { + const data = await versionRes.json(); + if (typeof data?.current === 'string') setVersion({ current: data.current, status: data.status ?? null }); + } + + const w: string[] = []; + if (adminConfigRes.ok) { + const sources = await adminConfigRes.json(); + setConfigSources(sources); + const sessionSecret = sources?.sessionSecret; + // Server redacts the raw value for sensitive keys; rely on hasValue, + // which is false when unset or matching a known placeholder default. + if (!sessionSecret?.hasValue) { + w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.'); + } + const adminPassword = sources?.adminPassword; + if (adminPassword?.value && adminPassword.source === 'env') { + w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.'); + } + } + setWarnings(w); + } + + const jmapUrl = config?.jmapServerUrl || '-'; + const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-'; + + const newer = version?.status?.latest && version.status.latest !== version.current ? version.status.latest : null; + const severity = version?.status?.severity ?? 'none'; + const versionSub: { text?: string; tone: StatTone } = !version + ? { tone: 'muted' } + : newer + ? { text: `v${newer} available`, tone: severity === 'security' || severity === 'deprecated' ? 'danger' : 'warning' } + : { text: 'Up to date', tone: 'muted' }; + + return ( +
+ + +
+ {warnings.length > 0 && ( +
+ {warnings.map((msg, i) => ( +
+ +

{msg}

+
+ ))} +
+ )} + +
+ + 0 ? `${pendingApprovals} awaiting approval` : undefined} + subTone="warning" + /> + + +
+ + + + {config?.appName || '-'} + + + {jmapHostname} + + + + {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} + + + + + + + + + + + + + + + {recentActivity.length === 0 ? ( +

No activity recorded yet

+ ) : ( + + {recentActivity.map((entry, i) => ( + {entry.action}} + description={formatDetail(entry.detail) || undefined} + meta={<>{entry.ip} · {new Date(entry.ts).toLocaleString()}} + /> + ))} + + )} + + All entries + +
+
+
+ ); +} + +type StatTone = 'muted' | 'warning' | 'danger'; + +interface StatTileProps { + label: string; + value: ReactNode; + sub?: ReactNode; + subTone?: StatTone; + /** Makes the tile a link to that admin page. */ + href?: string; +} + +const SUB_TONE: Record = { + muted: 'text-muted-foreground', + warning: 'text-amber-600 dark:text-amber-400', + danger: 'text-destructive', +}; + +function StatTile({ label, value, sub, subTone = 'muted', href }: StatTileProps) { + const body = ( + <> +
{label}
+
{value}
+ {sub &&
{sub}
} + + ); + const className = 'block min-w-0 rounded-lg border border-border p-4'; + if (href) { + return ( + + {body} + + ); + } + return
{body}
; +} + +function OnOff({ on }: { on: boolean }) { + return {on ? 'On' : 'Off'}; +} + +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/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx new file mode 100644 index 000000000..51d9d8e2b --- /dev/null +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -0,0 +1,494 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Puzzle, SwatchBook, Loader2, Eye, EyeOff, Lock, RotateCcw, Trash2, Shield, ShieldAlert, Store, Upload } from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { adminPath } from '@/lib/admin/admin-nav'; +import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { navigateAdmin } from '@/hooks/use-admin-navigation'; +import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; +import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; +import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminLink } from '@/components/admin/admin-link'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Badge } from '@/components/admin/badge'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { inputClass } from '@/components/admin/config-items'; +import { ExtensionIcon } from '@/components/admin/extension-icon'; +import { cn } from '@/lib/utils'; +import { useExtensionIcons } from './use-extension-icons'; + +export interface ConfigField { + type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; + label: string; + description?: string; + required?: boolean; + default?: unknown; + placeholder?: string; + options?: { label: string; value: string }[]; +} + +/** A plugin or theme as /api/admin/plugins and /api/admin/themes list it. */ +export interface ExtensionEntry { + id: string; + name: string; + version: string; + author: string; + description: string; + /** Plugins: hook, ui-extension, ... */ + type?: string; + /** Themes: the colour modes it ships. */ + variants?: string[]; + enabled: boolean; + forceEnabled?: boolean; + permissions?: string[]; + installedAt: string; + updatedAt: string; + source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' }; + /** Versions kept on this server for a rollback, most recently replaced first. */ + previousVersions?: string[]; + /** Loaded from the dev plugins directory rather than installed. */ + dev?: boolean; + configSchema?: Record; +} + +type Kind = 'plugin' | 'theme'; + +const KIND_LABEL: Record = { + plugin: { one: 'plugin', many: 'Plugins', endpoint: '/api/admin/plugins' }, + theme: { one: 'theme', many: 'Themes', endpoint: '/api/admin/themes' }, +}; + +interface Props { + kind: Kind; + id: string; +} + +/** + * One installed plugin or theme: its settings first, then whether users + * can turn it off, what it may do, and the versions kept for a rollback. + */ +export function ExtensionDetailPage({ kind, id }: Props) { + const words = KIND_LABEL[kind]; + const listHref = adminPath(kind === 'plugin' ? 'plugins' : 'themes'); + const [entry, setEntry] = useState(null); + const [loading, setLoading] = useState(true); + const [config, setConfig] = useState>({}); + const [formValues, setFormValues] = useState>({}); + const [revealSecrets, setRevealSecrets] = useState>({}); + const [saving, setSaving] = useState(false); + const [busy, setBusy] = useState(null); + const [message, setMessage] = useFlash(); + const iconFor = useExtensionIcons(); + + const load = useCallback(async () => { + const [listRes, configRes] = await Promise.all([ + apiFetch(words.endpoint), + kind === 'plugin' ? apiFetch(`/api/admin/plugins/${encodeURIComponent(id)}/config`) : Promise.resolve(null), + ]); + if (listRes.ok) { + const entries: ExtensionEntry[] = await listRes.json(); + setEntry(entries.find((e) => e.id === id) ?? null); + } + if (configRes?.ok) setConfig(await configRes.json()); + }, [id, kind, words.endpoint]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + load().finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [load]); + + const schema = entry?.configSchema; + const hasSchema = !!schema && Object.keys(schema).length > 0; + + const savedValues = useMemo(() => { + const initial: Record = {}; + if (!schema) return initial; + for (const [key, field] of Object.entries(schema)) { + const stored = config[key]; + if (stored !== undefined && stored !== null) initial[key] = String(stored); + else if (field.default !== undefined) initial[key] = String(field.default); + else initial[key] = ''; + } + return initial; + }, [schema, config]); + + useEffect(() => { + if (!schema) return; + setFormValues(savedValues); + }, [schema, savedValues]); + + const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]); + useAdminUnsavedChanges(`${kind}-config`, dirty); + + async function saveConfig() { + if (!schema) return; + for (const [key, field] of Object.entries(schema)) { + if (field.required && !formValues[key]?.trim()) { + setMessage({ type: 'error', text: `"${field.label}" is required` }); + return; + } + } + setSaving(true); + setMessage(null); + try { + let firstError: string | null = null; + let failed = false; + const noteError = async (res: Response) => { + failed = true; + if (firstError) return; + const data = await res.json().catch(() => null); + if (typeof data?.error === 'string') firstError = data.error; + }; + const endpoint = `/api/admin/plugins/${encodeURIComponent(id)}/config`; + for (const [key, field] of Object.entries(schema)) { + const newVal = formValues[key] ?? ''; + const oldVal = config[key] !== undefined ? String(config[key]) : ''; + if (newVal === oldVal) continue; + if (field.type === 'secret' && !newVal && config[key]) continue; + + if (!newVal && !field.required) { + const res = await apiFetch(endpoint, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) setConfig((prev) => { const next = { ...prev }; delete next[key]; return next; }); + else await noteError(res); + continue; + } + + let value: unknown = newVal; + if (field.type === 'boolean') value = newVal === 'true'; + else if (field.type === 'number') value = Number(newVal); + const res = await apiFetch(endpoint, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }); + if (res.ok) setConfig((prev) => ({ ...prev, [key]: value })); + else await noteError(res); + } + setMessage(failed + ? { type: 'error', text: firstError ?? 'Some settings could not be saved' } + : { type: 'success', text: 'Settings saved' }); + } catch { + setMessage({ type: 'error', text: 'Could not save the settings' }); + } finally { + setSaving(false); + } + } + + async function patch(body: Record, what: string): Promise { + setBusy(what); + setMessage(null); + try { + const res = await apiFetch(words.endpoint, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...body }), + }); + if (res.ok) return true; + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || `Could not change the ${words.one}` }); + return false; + } finally { + setBusy(null); + } + } + + async function setEnabled(enabled: boolean) { + if (await patch({ enabled }, 'enabled')) setEntry((prev) => (prev ? { ...prev, enabled } : prev)); + } + + async function setForced(forceEnabled: boolean) { + const body: Record = { forceEnabled }; + if (forceEnabled) body.enabled = true; + if (await patch(body, 'forced')) { + setEntry((prev) => (prev ? { ...prev, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : prev)); + } + } + + async function rollback(version: string) { + if (!entry) return; + if (!confirm(`Put v${version} of "${entry.name}" back in place of v${entry.version}? The copy kept on this server is used.`)) return; + setBusy(`rollback:${version}`); + setMessage(null); + try { + const res = await apiFetch(`/api/admin/extensions/${kind}/${encodeURIComponent(id)}/rollback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setMessage({ type: 'success', text: `"${entry.name}" rolled back to v${version}` }); + await load(); + } else { + setMessage({ type: 'error', text: data.error || 'Rollback failed' }); + } + } finally { + setBusy(null); + } + } + + async function remove() { + if (!entry) return; + if (!confirm(`Remove "${entry.name}" for every user? Its kept versions are removed too; this cannot be undone.`)) return; + setBusy('remove'); + setMessage(null); + const res = await apiFetch(words.endpoint, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (res.ok) { + navigateAdmin(listHref); + return; + } + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not remove it' }); + setBusy(null); + } + + const back = { href: listHref, label: words.many }; + const Icon = kind === 'plugin' ? Puzzle : SwatchBook; + + if (loading && !entry) { + return ( +
+ Loading} /> +
+ Loading +
+
+ ); + } + + if (!entry) { + return ( +
+ Nothing is installed under the id {id}.} /> +
+ ); + } + + const perms = [...new Set(entry.permissions ?? [])] + .sort((a, b) => Number(HIGH_RISK_PERMISSIONS.has(b)) - Number(HIGH_RISK_PERMISSIONS.has(a))); + const previous = entry.previousVersions ?? []; + + return ( +
+ + +
+ )} + title={( + + {entry.name} + v{entry.version} + {entry.forceEnabled && }>Forced on} + {entry.dev && Dev} + + )} + description={( + <> + by {entry.author || 'unknown'} + {entry.type && <> · {entry.type}} + {entry.variants && entry.variants.length > 0 && <> · {entry.variants.join(', ')}} + {entry.installedAt && <> · installed {new Date(entry.installedAt).toLocaleDateString()}} + + )} + actions={( + <> + + + + )} + /> + + {entry.description &&

{entry.description}

} + + setMessage(null)} /> + +
+ + + void setForced(v)} /> + + + {entry.source?.kind === 'marketplace' ? ( + + Open in marketplace + + ) : ( + Upload + )} + + + + {kind === 'plugin' && ( + + {perms.length > 0 && ( +
    + {perms.map((perm) => { + const risky = HIGH_RISK_PERMISSIONS.has(perm); + return ( +
  • + {risky + ? + : } + {perm} + {risky && High risk} +
  • + ); + })} +
+ )} +
+ )} + + + + v{entry.version} + + {previous.map((version) => ( + + + + ))} + + + {kind === 'plugin' && } + + {/* The plugin's own settings come last: they are the long part. */} + {kind === 'plugin' && hasSchema && schema && ( + + {Object.entries(schema).map(([key, field]) => ( + + setRevealSecrets((prev) => ({ ...prev, [key]: !prev[key] }))} + onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))} + /> + + ))} + + )} +
+ + setFormValues(savedValues)} + saveLabel="Save settings" + /> +
+ ); +} + +function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange }: { + field: ConfigField; + value: string; + stored: boolean; + revealed: boolean; + onReveal: () => void; + onChange: (value: string) => void; +}) { + const width = 'w-full sm:w-64'; + if (field.type === 'boolean') { + return ( + onChange(v ? 'true' : 'false')} /> + ); + } + if (field.type === 'select' && field.options) { + return ( + + ); + } + if (field.type === 'secret') { + return ( +
+ onChange(e.target.value)} + placeholder={stored ? 'Saved. Type to replace' : field.placeholder || ''} + aria-label={field.label} + autoComplete="off" + className={cn(inputClass, 'w-full pe-9 font-mono')} + /> + +
+ ); + } + return ( + onChange(e.target.value)} + placeholder={field.placeholder || ''} + aria-label={field.label} + autoComplete="off" + className={cn(inputClass, width)} + /> + ); +} + +/** + * The plugin's own `admin-plugin-page` slot, if it offers one. Sandboxed + * plugins ship a React component under `slots['admin-plugin-page']` and the + * host gives it an iframe here. + */ +function PluginAdminSection({ pluginId }: { pluginId: string }) { + const offers = usePluginSlotOffers('admin-plugin-page'); + const offer = offers.find((o) => o.pluginId === pluginId); + if (!offer) return null; + return ( + +
+ +
+
+ ); +} diff --git a/app/(main)/admin/_pages/logs.tsx b/app/(main)/admin/_pages/logs.tsx new file mode 100644 index 000000000..85f56a1b9 --- /dev/null +++ b/app/(main)/admin/_pages/logs.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { RefreshCw, Loader2, ScrollText } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { Badge } from '@/components/admin/badge'; +import { EmptyState } from '@/components/admin/empty-state'; +import { inputClass } from '@/components/admin/config-items'; +import type { AuditEntry } from '@/lib/admin/types'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { apiFetch } from '@/lib/browser-navigation'; +import { cn } from '@/lib/utils'; + +const PAGE = adminPageDef('logs'); + +export function LogsPage() { + 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)); + const initialLoading = loading && entries.length === 0; + + return ( +
+ {PAGE.description} · {total} total entries} + actions={( + <> + + + + )} + /> + + {initialLoading ? ( +
+ Loading +
+ ) : entries.length === 0 ? ( + + ) : ( + <> +
+ {entries.map((entry, i) => ( +
+
+ {entry.action} + + {new Date(entry.ts).toLocaleString()} + +
+
+ {formatDetail(entry.detail)} +
+
+ {entry.ip} +
+
+ ))} +
+ +
+ + + + + + + + + + + {entries.map((entry, i) => ( + + + + + + + ))} + +
TimeActionDetailsIP
+ {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/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx new file mode 100644 index 000000000..aae7b42f9 --- /dev/null +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -0,0 +1,838 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + ArrowUpCircle, + Download, + Loader2, + Puzzle, + SwatchBook, + Star, + Trash2, + Check, + Settings as SettingsIcon, + ExternalLink, + Shield, + ShieldAlert, + ShieldCheck, + AlertTriangle, + FileCode, + ChevronDown, + ChevronUp, + RotateCcw, + Ban, + Info, + Eye, +} from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { compareVersions, isVersionSatisfied } from '@/lib/version-compare'; +import type { MarketplaceListing } from '@/lib/admin/marketplace-directory'; +import { adminPath } from '@/lib/admin/admin-nav'; +import { cn } from '@/lib/utils'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminLink } from '@/components/admin/admin-link'; +import { AdminButtonLink } from '@/components/admin/button-link'; +import { Badge } from '@/components/admin/badge'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { installVersion } from './marketplace'; + +const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; + +interface VersionRow { + version: string; + channel: 'stable' | 'beta'; + publishedAt: string | null; + minAppVersion: string | null; + maxAppVersion: string | null; + permissions: string[]; + size: number; + sha256: string; + yanked: { at: string; reason: string | null } | null; + changelog: string | null; + downloads: number; + signed: boolean; + commit: string | null; +} + +interface Advisory { + id: string; + affected: string; + fixedIn: string | null; + severity: string; + summary: string; + details: string | null; + withdrawn: boolean; +} + +interface HistoryEntry { + version: string; + savedAt: string; + contentHash: string; +} + +interface PreviewData { + extension: MarketplaceListing & { + longDescription: string | null; + newestVersion: string | null; + newestMinAppVersion: string | null; + versions: VersionRow[]; + screenshots: Array<{ url: string; altText: string | null }>; + themePreviews: Array<{ variant: 'light' | 'dark'; url: string | null; colors: Record | null }>; + advisories: Advisory[]; + permissionDetails: Array<{ id: string; title: string; body: string; highRisk: boolean }>; + source: { host: string; repo: string | null; subpath: string | null }; + createdAt: string | null; + updatedAt: string | null; + }; + preview: { version: string; channel: 'stable' | 'beta'; yanked: VersionRow['yanked']; minAppVersion: string | null; maxAppVersion: string | null } | null; + requestedVersionMissing: string | null; + bundle: { + manifest: Record | null; + source: { name: string; content: string; truncated: boolean } | null; + size: number; + error: string | null; + signature: { status: 'verified' | 'unsigned' | 'invalid'; keyId?: string; reason?: string } | null; + }; + installed: boolean; + installedVersion: string | null; + installedSource: { kind: 'marketplace' | 'upload' } | null; + history: HistoryEntry[]; + channel: 'stable' | 'beta'; + appVersion: string | null; + directoryUrl: string; +} + +const RISKY_PERMISSIONS = new Set([ + 'mail:write', + 'mail:delete', + 'storage:write', + 'network', + 'admin', +]); + +/** Whether this Bulwark can run a version, given what the directory says it needs. */ +function fits(v: { minAppVersion: string | null; maxAppVersion: string | null }): boolean { + if (!isVersionSatisfied(CURRENT_APP_VERSION, v.minAppVersion)) return false; + if (v.maxAppVersion && compareVersions(CURRENT_APP_VERSION, v.maxAppVersion) > 0) return false; + return true; +} + +const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : ''); +const fmtSize = (bytes: number) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024).toFixed(1)} KB`); + +const BACK = { href: adminPath('marketplace'), label: 'Marketplace' }; + +const thClass = 'px-4 py-2 text-start text-xs font-medium text-muted-foreground'; +const tdClass = 'px-4 py-2 align-top'; +const smallButtonClass = 'h-8 gap-1 text-xs'; + +export function MarketplaceDetailPage({ slug }: { slug: string }) { + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [uninstalling, setUninstalling] = useState(false); + const [message, setMessage] = useFlash(); + const [showSource, setShowSource] = useState(false); + const [showManifest, setShowManifest] = useState(false); + // The version being previewed, from the install link (?version=) or a row + // the admin picked. Read from the address bar, like the connector does. + const [selected, setSelected] = useState(undefined); + + useEffect(() => { + setSelected(new URLSearchParams(window.location.search).get('version')); + }, []); + + const fetchPreview = useCallback(async (version: string | null) => { + setLoading(true); + setError(null); + try { + const query = version ? `?version=${encodeURIComponent(version)}` : ''; + const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}${query}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error || 'Failed to load preview'); + return; + } + setData(await res.json()); + } catch { + setError('Failed to connect to extension directory'); + } finally { + setLoading(false); + } + }, [slug]); + + useEffect(() => { + if (selected === undefined) return; + fetchPreview(selected); + }, [fetchPreview, selected]); + + function pick(version: string | null) { + const url = new URL(window.location.href); + if (version) url.searchParams.set('version', version); + else url.searchParams.delete('version'); + window.history.replaceState(null, '', url.toString()); + setSelected(version); + setMessage(null); + } + + async function handleInstall(version: string) { + if (!data) return; + const isUpdate = data.installed; + setBusy(`install:${version}`); + setMessage(null); + const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type }); + setBusy(null); + if (!result.ok) { + setMessage({ type: 'error', text: result.error }); + return; + } + const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : ''; + const signed = result.signature === 'verified' ? ', signed by the directory' : ''; + setMessage({ + type: 'success', + text: isUpdate + ? `"${data.extension.name}" is now v${version}${signed}${warnings}` + : `"${data.extension.name}" installed (v${version})${signed}${warnings}`, + }); + fetchPreview(selected ?? null); + } + + async function handleRollback(version: string) { + if (!data) return; + if (!confirm(`Put v${version} of "${data.extension.name}" back in place of v${data.installedVersion}? The copy kept on this server is used; the directory is not asked.`)) return; + setBusy(`rollback:${version}`); + setMessage(null); + try { + const res = await apiFetch(`/api/admin/extensions/${data.extension.type}/${encodeURIComponent(data.extension.slug)}/rollback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version }), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) { + setMessage({ type: 'success', text: `"${data.extension.name}" rolled back to v${version}` }); + fetchPreview(selected ?? null); + } else { + setMessage({ type: 'error', text: body.error || 'Rollback failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Rollback failed - network error' }); + } finally { + setBusy(null); + } + } + + async function handleUninstall() { + if (!data) return; + if (!confirm(`Remove "${data.extension.name}"? Its kept versions are removed too; this cannot be undone.`)) return; + + setUninstalling(true); + setMessage(null); + try { + const endpoint = data.extension.type === 'theme' + ? '/api/admin/themes' + : '/api/admin/plugins'; + const res = await apiFetch(endpoint, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: data.extension.slug }), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) { + setMessage({ type: 'success', text: `"${data.extension.name}" removed` }); + fetchPreview(selected ?? null); + } else { + setMessage({ type: 'error', text: body.error || 'Uninstall failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Uninstall failed - network error' }); + } finally { + setUninstalling(false); + } + } + + if (loading && !data) { + return ( +
+ Loading} /> +
+ Loading +
+
+ ); + } + + if (error || !data) { + return ( +
+ Nothing in the directory is listed under {slug}.} + /> +
+ ); + } + + const ext = data.extension; + const bundle = data.bundle; + const preview = data.preview; + const isPlugin = ext.type === 'plugin'; + const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || preview && ext.versions.find((v) => v.version === preview.version)?.permissions || ext.permissions || []; + const permissionText = new Map(ext.permissionDetails.map((p) => [p.id, p])); + const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; + const settingsSchema = bundle.manifest?.settingsSchema as Record | undefined; + + const previewFits = preview ? fits(preview) : false; + const previewIsInstalled = !!preview && data.installed && data.installedVersion === preview.version; + const previewDirection = preview && data.installed && data.installedVersion + ? compareVersions(preview.version, data.installedVersion) + : 0; + const installedRow = data.installedVersion ? ext.versions.find((v) => v.version === data.installedVersion) ?? null : null; + const suspended = ext.status === 'suspended'; + const historyVersions = new Set(data.history.map((h) => h.version)); + const canInstallPreview = !!preview && !bundle.error && previewFits && !suspended && bundle.signature?.status !== 'invalid'; + + // Versions no longer in the directory's list but still kept here. + const extraHistory = data.history.filter((h) => !ext.versions.some((v) => v.version === h.version)); + + const installLabel = !preview + ? 'Install' + : previewIsInstalled + ? `Reinstall v${preview.version}` + : !data.installed + ? `Install v${preview.version}` + : previewDirection > 0 + ? `Update to v${preview.version}` + : `Install v${preview.version} (older)`; + + const updateAvailable = data.installed && !!ext.latest && !!data.installedVersion && compareVersions(ext.latest.version, data.installedVersion) > 0; + + return ( +
+ + {ext.iconUrl ? ( + + ) : isPlugin ? ( + + ) : ( + + )} +
+ )} + title={( + + {ext.name} + {ext.featured && } + {data.installed && ( + }>Installed v{data.installedVersion} + )} + {updateAvailable && ( + + )} + {ext.deprecated && Deprecated} + {suspended && }>Suspended} + + )} + description={( + + {isPlugin ? (ext.pluginType || 'plugin') : 'theme'} + {ext.author && ( + + by {ext.author.displayName} + {ext.author.verified && } + + )} + {ext.license && {ext.license}} + + + {ext.totalDownloads.toLocaleString()} + + {ext.rating.count > 0 && ( + + + {ext.rating.average.toFixed(1)} ({ext.rating.count}) + + )} + + )} + actions={( + <> + {preview && ( + + )} + {data.installed && ( + <> + + + Manage + + + + )} + + + Directory + + + )} + /> + + {ext.bannerUrl && ( +
+ +
+ )} + + setMessage(null)} /> + +
+ {data.requestedVersionMissing && ( + + Showing v{preview?.version} instead. + + )} + {suspended && ( + + It cannot be installed. {data.installed ? 'Consider removing it.' : ''} + + )} + {ext.deprecated && ( + + {ext.deprecated.message || 'Its publisher no longer maintains it.'} + {ext.deprecated.replacement && <> Use {ext.deprecated.replacement} instead.} + + )} + {ext.advisories.filter((a) => !a.withdrawn).map((a) => ( + + {a.summary} Affects {a.affected}{a.fixedIn ? `; fixed in v${a.fixedIn}` : '; no fixed version yet'}. + {installedRow && data.installedVersion && a.fixedIn && compareVersions(data.installedVersion, a.fixedIn) < 0 && ( + <> Your installed v{data.installedVersion} is affected. + )} + + ))} + {installedRow?.yanked && ( + + {installedRow.yanked.reason || 'No reason was given.'} Pick another version below. + + )} + {preview?.yanked && !previewIsInstalled && ( + + {preview.yanked.reason || 'No reason was given.'} It can still be installed, but you will be asked to confirm. + + )} + {preview && !previewFits && ( + 0 ? 'This version is for an older Bulwark' : 'Update Bulwark to install this version'}> + v{preview.version} needs Bulwark {preview.minAppVersion ?? 'any version'}{preview.maxAppVersion ? ` up to ${preview.maxAppVersion}` : ' or later'}. You are running v{CURRENT_APP_VERSION}. + {!preview.yanked && ext.latest && ext.latest.version !== preview.version && ( + <> runs here. + )} + + )} + {!ext.latest && !preview && ( + + {ext.newestVersion ? `The newest, v${ext.newestVersion}, needs Bulwark ${ext.newestMinAppVersion ?? 'a newer version'}.` : 'Nothing has been published yet.'} + + )} + {bundle.error && ( + {bundle.error} + )} + {bundle.signature?.status === 'invalid' && ( + + {bundle.signature.reason}. It will not be installed. + + )} +
+ +
+ +
+

{ext.description}

+ {ext.longDescription && ext.longDescription !== ext.description && ( +

{ext.longDescription}

+ )} + {ext.tags.length > 0 && ( +
+ {ext.tags.map(tag => {tag})} +
+ )} +
+ {preview && Previewing v{preview.version}{preview.channel === 'beta' ? ' (beta)' : ''}} + {bundle.size > 0 && Bundle: {fmtSize(bundle.size)}} + {bundle.signature?.status === 'verified' && ( + + Signed by the directory + + )} + {bundle.signature?.status === 'unsigned' && ( + + Signature not checked + + )} + {ext.source.repo && ( + + + {ext.source.host === 'github.com' ? '' : `${ext.source.host}/`}{ext.source.repo} + + )} +
+
+
+ + +
+ + + + + + + + + + + + + {ext.versions.map((v) => { + const isInstalled = data.installed && data.installedVersion === v.version; + const isPreview = preview?.version === v.version; + const kept = historyVersions.has(v.version); + const runs = fits(v); + const key = `install:${v.version}`; + return ( + + + + + + + + + ); + })} + {extraHistory.map((h) => ( + + + + + + + + + ))} + {ext.versions.length === 0 && extraHistory.length === 0 && ( + + )} + +
VersionReleasedNeeds BulwarkSizeStatusActions
+
+ v{v.version} + {v.channel === 'beta' && beta} + {v.signed && } +
+ {v.changelog &&

{v.changelog}

} +
{fmtDate(v.publishedAt)} + {v.minAppVersion ?? 'any'}{v.maxAppVersion ? ` to ${v.maxAppVersion}` : v.minAppVersion ? '+' : ''} + {fmtSize(v.size)} +
+ {isInstalled && Installed} + {v.yanked && Withdrawn} + {!runs && Not for v{CURRENT_APP_VERSION}} + {kept && !isInstalled && Kept here} +
+
+
+ {!isPreview && ( + + )} + {kept && !isInstalled && data.installed && ( + + )} + {!isInstalled && !kept && runs && !suspended && ( + + )} +
+
+ v{h.version} +

No longer in the directory

+
replaced {fmtDate(h.savedAt)}–– + Kept here + +
+ {data.installed && ( + + )} +
+
No versions published.
+
+
+ + {ext.screenshots.length > 0 && ( + +
+ {ext.screenshots.map((s, i) => ( + {s.altText + ))} +
+
+ )} + + {!isPlugin && ext.themePreviews.length > 0 && ( + +
+ {ext.themePreviews.map(preview => ( + preview.url + ? ( +
+ {`${ext.name} +
{preview.variant}
+
+ ) + : + ))} +
+
+ )} + + {isPlugin && ( + + {manifestPerms.length > 0 && ( +
    + {manifestPerms.map(perm => { + const text = permissionText.get(perm); + const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm); + return ( +
  • + {risky ? : } +
    +
    + {text && {text.title}} + {perm} +
    + {text?.body &&

    {text.body}

    } +
    +
  • + ); + })} +
+ )} + {frameOrigins.length > 0 && ( +
+

Iframe origins

+

+ The plugin will be allowed to embed content from these origins. +

+
    + {frameOrigins.map(origin => ( +
  • + {origin} +
  • + ))} +
+
+ )} +
+ )} + + {isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && ( + +
    + {Object.entries(settingsSchema).map(([key, field]) => ( +
  • +
    + {key} + {field.type} +
    +
    {field.label}
    + {field.description && ( +
    {field.description}
    + )} +
  • + ))} +
+
+ )} + + {bundle.manifest && ( + +
+ + {showManifest && ( +
+                  {JSON.stringify(bundle.manifest, null, 2)}
+                
+ )} +
+
+ )} + + {bundle.source && ( + +
+ + {showSource && ( +
+                  {bundle.source.content}
+                
+ )} +
+
+ )} +
+
+ ); +} + +const NOTICE_TONES = { + danger: 'border-destructive/30 bg-destructive/10 text-destructive', + warning: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300', + info: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-300', +} as const; + +function Notice({ tone, title, children }: { tone: 'info' | 'warning' | 'danger'; title: string; children?: React.ReactNode }) { + const Icon = tone === 'danger' ? ShieldAlert : tone === 'warning' ? AlertTriangle : Info; + return ( +
+ +
+

{title}

+ {children &&

{children}

} +
+
+ ); +} + +function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record | null } }) { + const colors = preview.colors || {}; + const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff'); + const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a'); + const accent = colors.primary || colors.accent || '#7c5cff'; + const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5'); + const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5'); + + return ( +
+
+ {preview.variant} +
+
+
+ + Sample text +
+
+ Card surface +
+
+ {Object.entries(colors).slice(0, 6).map(([key, value]) => ( + + ))} +
+
+
+ ); +} diff --git a/app/(main)/admin/_tabs/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx similarity index 51% rename from app/(main)/admin/_tabs/marketplace.tsx rename to app/(main)/admin/_pages/marketplace.tsx index de584242e..a58925975 100644 --- a/app/(main)/admin/_tabs/marketplace.tsx +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useState, useCallback, useMemo } from 'react'; -import Link from 'next/link'; import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle, ExternalLink, RefreshCw, Ban, ShieldAlert, Info, Upload, @@ -9,6 +8,18 @@ import { import { apiFetch } from '@/lib/browser-navigation'; import { compareVersions } from '@/lib/version-compare'; import type { MarketplaceListing } from '@/lib/admin/marketplace-directory'; +import { adminPageDef, adminPath } from '@/lib/admin/admin-nav'; +import { cn } from '@/lib/utils'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminLink } from '@/components/admin/admin-link'; +import { AdminButtonLink } from '@/components/admin/button-link'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { Badge } from '@/components/admin/badge'; +import { EmptyState } from '@/components/admin/empty-state'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { ConfigNote, inputClass } from '@/components/admin/config-items'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; @@ -52,8 +63,17 @@ const SORTS: Array<{ value: Sort; label: string }> = [ { value: 'name', label: 'Name' }, ]; +const PAGE = adminPageDef('marketplace'); + export const detailHref = (slug: string, version?: string | null) => - `/admin/marketplace/${encodeURIComponent(slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`; + `${adminPath('marketplace', slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`; + +/** The segmented Browse / Installed and All / Plugins / Themes switches. */ +const pillGroupClass = 'flex items-center gap-0.5 rounded-md bg-muted p-0.5 self-start'; +const pillClass = (active: boolean) => cn( + 'h-8 rounded px-3 text-sm font-medium transition-colors', + active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', +); /** * Installs one version of an extension. A version the publisher withdrew, @@ -92,7 +112,7 @@ export async function installVersion( } } -export function MarketplaceTab() { +export function MarketplacePage() { const [extensions, setExtensions] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(''); @@ -105,7 +125,7 @@ export function MarketplaceTab() { const [directoryUrl, setDirectoryUrl] = useState(null); const [channel, setChannel] = useState<'stable' | 'beta'>('stable'); const [installing, setInstalling] = useState>(new Set()); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [message, setMessage] = useFlash(); const [error, setError] = useState(null); const [updates, setUpdates] = useState(null); const [updatesLoading, setUpdatesLoading] = useState(true); @@ -217,195 +237,161 @@ export function MarketplaceTab() { : 0; return ( -
-
-
-

Marketplace

-

- Plugins and themes from the extension directory, installed for every user of this Bulwark -

-
-
- {channel === 'beta' && ( - - Beta channel - - )} - {directoryUrl && ( - - - Open directory - - )} -
-
- - {message && ( -
- {message.text} -
- )} - - {updates && noticeCount > 0 && ( - { - const entry = updates.installed.find((e) => e.id === u.slug); - if (entry) install({ slug: u.slug, name: entry.name, type: entry.kind }, u.version, true); - }} - onUpdateAll={updateAll} - /> - )} - -
-
- {([['browse', 'Browse'], ['installed', `Installed${installedCount ? ` (${installedCount})` : ''}`]] as const).map(([v, label]) => ( - - ))} -
-
- - setSearchInput(e.target.value)} - className="w-full h-9 ps-9 pe-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" - /> -
- {view === 'browse' && ( +
+ -
- {(['all', 'plugin', 'theme'] as const).map((t) => ( - ))}
- - - )} -
- - {view === 'installed' ? ( - install({ slug: entry.id, name: entry.name, type: entry.kind }, version, true)} - /> - ) : ( - <> - {error && ( -
- -

{error}

-

- The directory needs its v2 API (extensions.bulwarkmail.org has it; a self-hosted one must be from October 2026 or later) -

- +
+ + setSearchInput(e.target.value)} + aria-label={view === 'browse' ? 'Search extensions' : 'Filter installed'} + className={cn(inputClass, 'w-full ps-9')} + />
- )} - - {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) => ( - ext.latestVersion && install(ext, ext.latestVersion, ext.installed)} - /> - ))} -
- - {totalPages > 1 && ( -
- - - Page {page} of {totalPages} - - + {view === 'browse' && ( + <> +
+ {(['all', 'plugin', 'theme'] as const).map((t) => ( + + ))}
+ + + )} +
+ + {view === 'installed' ? ( + install({ slug: entry.id, name: entry.name, type: entry.kind }, version, true)} + /> + ) : ( + <> + {error && ( + Retry} + /> + )} + + {loading && !error && ( +
+ Loading +
+ )} + + {!loading && !error && extensions.length === 0 && ( + + )} + + {!loading && !error && extensions.length > 0 && ( + <> +
+ {total} extension{total !== 1 ? 's' : ''} found +
+
+ {extensions.map((ext) => ( + ext.latestVersion && install(ext, ext.latestVersion, ext.installed)} + /> + ))} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + )} )} - - )} +
+
); } /** Updates and warnings for what is installed, from one directory request. */ -function UpdatesStrip({ +function UpdatesSection({ updates, installing, onUpdate, @@ -419,59 +405,57 @@ function UpdatesStrip({ const names = new Map(updates.installed.map((e) => [e.id, e.name])); const name = (slug: string) => names.get(slug) ?? slug; const busy = installing.size > 0; + const hasNotices = updates.advisories.length > 0 || updates.yanked.length > 0 || updates.suspended.length > 0 || updates.deprecated.length > 0; + const title = updates.updates.length > 0 + ? `${updates.updates.length} update${updates.updates.length === 1 ? '' : 's'} available` + : 'Notices'; return ( -
+ {updates.updates.length > 0 && ( -
-
-
- -

- {updates.updates.length} update{updates.updates.length === 1 ? '' : 's'} available -

-
- {updates.updates.length > 1 && ( - - )} -
-
    + +
+ )} +
    {updates.updates.map((u) => ( -
  • +
  • - {name(u.slug)} - v{u.currentVersion} → v{u.version} - {u.channel === 'beta' && beta} - {u.changelog &&

    {u.changelog}

    } +
    + {name(u.slug)} + v{u.currentVersion} → v{u.version} + {u.channel === 'beta' && beta} +
    + {u.changelog &&

    {u.changelog}

    }
    - +
  • ))}
)} - {(updates.advisories.length > 0 || updates.yanked.length > 0 || updates.suspended.length > 0 || updates.deprecated.length > 0) && ( -