From 2b01866399329433345a2212c4989f2c5022895e Mon Sep 17 00:00:00 2001 From: dealerweb Date: Tue, 20 Oct 2026 11:38:34 +0200 Subject: [PATCH 01/25] fix: show the hover actions on a row that moves under a resting pointer --- .../email/__tests__/thread-list-item.test.tsx | 28 +++++++++++++++++++ components/email/thread-list-item.tsx | 17 ++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 37c42021d..99156b232 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -781,3 +781,31 @@ describe('ThreadListItem date (#891)', () => { expect(container.textContent).not.toMatch(/2024/); }); }); + +describe('ThreadListItem hover actions', () => { + // When the row above is deleted from its hover actions, the next row slides + // under the resting pointer. The browser then reports a pointerover whose + // relatedTarget is the list around the row, and React builds no + // onPointerEnter from that, so the row has to arm its actions on pointerover. + const renderInList = (thread: ReturnType[number]) => + render( +
+ {}} onEmailSelect={() => {}} onDelete={() => {}} /> +
, + ); + + it.each([ + ['a single message', () => groupEmailsByThread([makeEmail()])[0]], + ['a conversation', () => groupEmailsByThread([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-15T11:00:00Z' }), + ])[0]], + ])('mounts them for %s that slides under a resting pointer', (_name, makeThread) => { + const { container } = renderInList(makeThread()); + expect(screen.queryByTestId('hover-action-delete')).toBeNull(); + const row = screen.queryByTestId('email-list-item') ?? container.querySelector('[data-unread]'); + expect(row).not.toBeNull(); + fireEvent.pointerOver(row!, { relatedTarget: screen.getByTestId('list') }); + expect(screen.getByTestId('hover-action-delete')).toBeTruthy(); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2ccafb6b6..eb04344a8 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -59,8 +59,11 @@ function isMousePressOnRow(e: React.PointerEvent): boolean { * Whether a row has been pointed at yet. The hover actions stay display:none * until the pointer is over the row, so mounting them for every row up front * only added a handful of icon buttons per row to every list render. They - * mount on the first pointer-enter and stay from then on, so the hover - * behaviour itself is unchanged. + * mount on the first pointer-over and stay from then on, so the hover + * behaviour itself is unchanged. Pointer-over rather than pointer-enter: when + * a row slides under a resting pointer (the row above was just deleted from + * its hover actions), the browser's pointerover comes from the list around + * it, and React builds no onPointerEnter from that. */ /** * Role of the folder the list shows (sender vs recipient, junk actions). In @@ -77,7 +80,7 @@ export function listMailboxRole(state: { ?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined); } -function useArmOnPointerEnter(): [boolean, () => void] { +function useArmOnPointerOver(): [boolean, () => void] { const [armed, setArmed] = React.useState(false); const arm = React.useCallback(() => setArmed(true), []); return [armed, arm]; @@ -429,7 +432,7 @@ const SingleEmailItem = React.forwardRef( sourceMailboxId: selectedMailbox, }); - const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter(); + const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver(); const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( useCallback((pos) => { onContextMenu?.( @@ -526,7 +529,7 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...touchHandlers} data-testid="email-list-item" - onPointerEnter={armHoverActions} + onPointerOver={armHoverActions} onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }} data-email-id={email.id} data-subject={email.subject || ''} @@ -980,7 +983,7 @@ export const ThreadListItem = React.forwardRef { onContextMenu?.( @@ -1126,7 +1129,7 @@ export const ThreadListItem = React.forwardRef { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} data-unread={hasUnread ? 'true' : 'false'} From 7a1aaf5f190244478a1d8a0926e5688073594839 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Tue, 20 Oct 2026 12:23:55 +0200 Subject: [PATCH 02/25] fix: stop the push preference mirror from throwing in sandboxed plugin frames --- lib/__tests__/push-preferences.test.ts | 14 ++++++++++++++ lib/push-preferences.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/push-preferences.test.ts b/lib/__tests__/push-preferences.test.ts index c1063835f..47a08b510 100644 --- a/lib/__tests__/push-preferences.test.ts +++ b/lib/__tests__/push-preferences.test.ts @@ -31,6 +31,20 @@ describe('writePushPreferences', () => { await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); expect(put).not.toHaveBeenCalled(); }); + + it('does nothing where reading the Cache store throws', async () => { + // A plugin sandbox frame has an opaque origin: reading `caches` there, + // typeof included, throws a SecurityError. + Object.defineProperty(globalThis, 'caches', { + configurable: true, + get() { + throw new DOMException('Cache storage is disabled because the context is sandboxed', 'SecurityError'); + }, + }); + const { writePushPreferences } = await import('../push-preferences'); + await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); + expect(put).not.toHaveBeenCalled(); + }); }); describe('settings store push preference mirror', () => { diff --git a/lib/push-preferences.ts b/lib/push-preferences.ts index fe8aa3c7e..a148e34a9 100644 --- a/lib/push-preferences.ts +++ b/lib/push-preferences.ts @@ -21,8 +21,11 @@ export function pushPreferencesKey(origin: string): string { } export async function writePushPreferences(prefs: PushPreferences): Promise { - if (typeof window === 'undefined' || typeof caches === 'undefined') return; + if (typeof window === 'undefined') return; try { + // A frame sandboxed without allow-same-origin (the plugin sandbox loads + // the settings store too) throws on any read of `caches`, typeof included. + if (typeof caches === 'undefined') return; const cache = await caches.open(PUSH_STATE_CACHE); await cache.put( pushPreferencesKey(window.location.origin), From 41b70853fa05eeab28a56c8399de72ac772da1a6 Mon Sep 17 00:00:00 2001 From: VasilisNtovolos Date: Tue, 20 Oct 2026 14:33:42 +0300 Subject: [PATCH 03/25] fix: preserve message ownership in Pro blob downloads --- .../mail/__tests__/blob-routing.test.tsx | 131 ++++++++++++++++++ components/pro/pro-email-tab-body.tsx | 50 ++++++- 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 668edfa81..b0777c3db 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -75,6 +75,8 @@ vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ o const { MailApp } = await import('../mail-app'); const { ConversationEmailBody } = await import('@/components/email/thread-conversation-view'); const { EmailViewer } = await import('@/components/email/email-viewer'); +const { ProEmailTabBody } = await import('@/components/pro/pro-email-tab-body'); +const { ProFolderTabBody } = await import('@/components/pro/pro-folder-tab-body'); const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox; const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; @@ -602,3 +604,132 @@ describe('conversation blob routing', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owner-a'); }); }); + + +// Pro hosts must carry the same message-scoped context as MailApp, including +// when the global mail selection is absent or belongs to another message. +type ProBlobAction = 'download' | 'preview click' | 'CID' | 'export'; +const proBlobActions: ProBlobAction[] = ['download', 'preview click', 'CID', 'export']; +const proRoutes = [ + { label: 'direct shared folder', secondary: false, owner: 'nrichmond', selection: 'null', unified: false }, + { label: 'secondary shared folder', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: false }, + { label: 'primary folder', secondary: false, owner: 'postal12345', selection: 'null', unified: false }, + { label: 'secondary primary folder', secondary: true, owner: 'secondary-primary', selection: 'unrelated', unified: false }, + { label: 'Unified tab, null selection', secondary: true, owner: 'nrichmond', selection: 'null', unified: true }, + { label: 'Unified tab, unrelated selection', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: true }, + { label: 'explicit secondary email tab', secondary: true, owner: 'nrichmond', selection: 'null', unified: false, emailTab: true }, +]; + +async function openProMessage(route: typeof proRoutes[number], action: ProBlobAction) { + const { a, b, fetchMock } = await conversationClients(); + const origin = route.secondary ? b : a; + const full = message({ + subject: 'Pro message', + attachments: [{ ...cardAttachment, name: 'photo.png', ...(action === 'CID' ? { cid: 'test-image' } : {}) }], + ...(action === 'CID' ? { + textBody: [], htmlBody: [{ partId: '1', blobId: 'html-blob', type: 'text/html', size: 30 }], + bodyValues: { '1': { value: '', isTruncated: false, isEncodingProblem: false } }, + } : {}), + }); + const stub = { ...full, sourceClientAccountId: 'login-other', sourceAccountId: route.owner }; + vi.spyOn(a, 'getEmail').mockResolvedValue(full); + vi.spyOn(b, 'getEmail').mockResolvedValue(full); + useSettingsStore.setState({ emailDownloadTemplate: '{subject}', mailAttachmentAction: action === 'preview click' ? 'preview' : 'download' }); + useEmailStore.setState({ + emails: route.unified ? [stub] : [], + selectedEmail: route.selection === 'null' ? null : message({ id: 'unrelated', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), + isUnifiedView: route.unified, selectedMailbox: route.unified ? '__unified_inbox__' : sharedMailbox.id, + accountMailboxes: { 'login-primary': [primaryMailbox, sharedMailbox], 'login-other': [primaryMailbox, sharedMailbox] }, + }); + if (route.unified || route.emailTab) { + render(); + } else { + const mailbox = route.owner === 'nrichmond' ? sharedMailbox : primaryMailbox; + vi.spyOn(origin, 'getEmails').mockResolvedValue({ emails: [full], total: 1, hasMore: false }); + render(); + fireEvent.click(await screen.findByText('Pro message')); + } + if (action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + else await screen.findAllByText('photo.png'); + return { a, b, origin, fetchMock, full }; +} + +async function actOnProBlob(action: ProBlobAction) { + if (action === 'download') await downloadAttachment(); + if (action === 'preview click') { + fireEvent.click((await screen.findAllByText('photo.png'))[0]); + // Preserve Pro's existing action: Preview clicks download, with no modal. + expect(screen.queryByText('Preview fetch')).not.toBeInTheDocument(); + } + if (action === 'export') await exportEmail(); +} +function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, index = 0) { + const host = route.secondary ? 'secondary' : 'primary'; + const blobPath = action === 'export' ? 'raw-blob/Pro%20message.eml?accept=message%2Frfc822' : 'same-blob/photo.png?accept=image%2Fpng'; + expectBlobRequest(`https://${host}.example.com/download/${route.owner}/${blobPath}`, `${host}-login`, index); +} + +describe('Pro blob routing', () => { + it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( + '$label: $action uses the message owner and authenticated client', async route => { + const { origin, fetchMock } = await openProMessage(route, route.action); + await actOnProBlob(route.action); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectProBlob(route, route.action); + if (route.owner === 'nrichmond') expect(origin.getEmail).toHaveBeenCalledWith('email', route.owner); + }, + ); + + it.each([proRoutes[0], proRoutes[1], proRoutes[4]].flatMap(route => proBlobActions.map(action => ({ ...route, action }))))( + '$label: $action retains its source after account and selection changes', async route => { + const { a, b, fetchMock } = await openProMessage(route, route.action); + if (route.action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + // Leave the loaded reader mounted while a different Mail pane navigates. + act(() => { + useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'other', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), selectedMailbox: 'inbox', isUnifiedView: false }); + useAuthStore.setState({ client: route.secondary ? b : a, activeAccountId: route.secondary ? 'login-other' : 'login-primary' }); + }); + // Switch through null, as reconnecting account switches can do. + act(() => useAuthStore.setState({ client: null })); + act(() => useAuthStore.setState({ client: route.secondary ? a : b, activeAccountId: route.secondary ? 'login-primary' : 'login-other' })); + await act(async () => {}); + await actOnProBlob(route.action); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + for (let index = 0; index < fetchMock.mock.calls.length; index++) expectProBlob(route, route.action, index); + }, + ); + + it('retains an in-flight Unified load across account and selection changes', async () => { + const { a, b, fetchMock } = await conversationClients(); + let finish!: (email: Email) => void; + vi.spyOn(b, 'getEmail').mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const wrongClient = vi.spyOn(a, 'getEmail'); + useEmailStore.setState({ emails: [message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' })], selectedEmail: null, isUnifiedView: true }); + render(); + await waitFor(() => expect(b.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + act(() => { + useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'unrelated' }), isUnifiedView: false, selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: b, activeAccountId: 'login-other' }); + }); + await act(async () => finish(message({ attachments: [{ ...cardAttachment, name: 'photo.png' }] }))); + await downloadAttachment(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectProBlob(proRoutes[4], 'download'); + expect(wrongClient).not.toHaveBeenCalled(); + expect(b.getEmail).toHaveBeenCalledTimes(1); + }); + + it('changes CID routing when a reused reader opens identical ids on another client/owner', async () => { + const { a, b, fetchMock } = await conversationClients(); + const full = inlineMessage({ attachments: [{ ...cardAttachment, name: 'photo.png', cid: 'test-image' }] }); + vi.spyOn(a, 'getEmail').mockResolvedValue(full); + vi.spyOn(b, 'getEmail').mockResolvedValue({ ...full }); + const reader = (login: string, owner: string) => ; + const { rerender } = render(reader('login-primary', 'owner-a')); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expectBlobRequest('https://primary.example.com/download/owner-a/same-blob/photo.png?accept=image%2Fpng', 'primary-login'); + rerender(reader('login-other', 'owner-b')); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expectBlobRequest('https://secondary.example.com/download/owner-b/same-blob/photo.png?accept=image%2Fpng', 'secondary-login', 1); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index b51b900ac..88dd3d838 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore } from "@/stores/email-store"; +import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { findDraftIdentityId } from "@/lib/reply-identity"; @@ -100,6 +100,17 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa const openComposeTabNow = useProTabStore((s) => s.openComposeTab); const [email, setEmail] = useState(null); + const [blobSource, setBlobSource] = useState({ client: null }); + // A reader owns its load, including an in-flight one across account switches. + // Only a different message or explicit tab context starts a new load. + const loadRef = useRef<{ + emailId: string; + clientOverride: typeof clientOverride; + accountId: typeof accountId; + blobSource: EmailBlobContext; + promise: Promise; + applied: boolean; + } | null>(null); const [isLoading, setIsLoading] = useState(true); const composerSessionIdRef = useRef(0); @@ -137,11 +148,34 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa useEffect(() => { let cancelled = false; if (!client) return; + let load = loadRef.current; + if (!load || load.emailId !== emailId || load.clientOverride !== clientOverride || load.accountId !== accountId) { + const state = useEmailStore.getState(); + const ref = state.selectedEmail?.id === emailId + ? state.selectedEmail + : state.emails.find((e) => e.id === emailId); + // Folder/search panes supply their own context. A primary folder must + // not inherit an unrelated globally selected shared folder's owner. + const source = accountId || clientOverride + ? { client, accountId: accountId ?? client.getAccountId() } + : resolveEmailBlobContext(ref ?? null, client); + load = { + emailId, clientOverride, accountId, blobSource: source, applied: false, + promise: accountId || clientOverride + ? client.getEmail(emailId, source.accountId) + : fetchEmailContent(client, emailId), + }; + loadRef.current = load; + } + const pending = load; + if (pending.applied) return; setIsLoading(true); - (accountId ? client.getEmail(emailId, accountId) : fetchEmailContent(client, emailId)) + pending.promise .then((loaded) => { if (cancelled) return; + pending.applied = true; setEmail(loaded); + setBlobSource(pending.blobSource); if (loaded) onLoadedRef.current?.(loaded); }) .catch((err) => { @@ -153,7 +187,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa return () => { cancelled = true; }; - }, [client, emailId, accountId, fetchEmailContent]); + }, [client, clientOverride, emailId, accountId, fetchEmailContent]); const currentMailboxRole = useMemo(() => { if (!email) return undefined; @@ -326,14 +360,15 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa } }, [client, email, moveToMailbox, onClose]); - const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => { - if (!client) return; + const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string, _forceDownload?: boolean, source?: EmailBlobContext) => { + const context = source ?? blobSource; + if (!context.client) return; try { - await client.downloadBlob(blobId, name, type); + await context.client.downloadBlob(blobId, name, type, context.accountId); } catch (err) { console.error('Download failed:', err); } - }, [client]); + }, [blobSource]); const handleQuickReply = useCallback(async (body: string) => { handleReply(body); @@ -394,6 +429,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa Date: Tue, 20 Oct 2026 15:14:18 +0200 Subject: [PATCH 04/25] feat: rework the admin panel around a settings-style sidebar and one URL per page The old panel kept its active tab in a store, so every page sat at /admin, the sidebar duplicated the webmail rail by hand, and each page was a stack of boxed cards with their own save buttons. Plugin settings were only reachable through a gear icon in a row of five. - one catch-all route for the whole panel; pages switch through history.pushState, so `/admin/plugins/`, the back button, bookmarks and connector links all work without a route transition - sidebar laid out like the user settings: back to mail, search over pages and their sections, two group headings, change password and sign out at the bottom; the phone shows a list, then the page - pages use SettingsSection rows instead of nested cards, with one page header, one flash message and one save bar at the bottom (Discard, Save, Ctrl+S); the unsaved-changes prompt still guards leaving - plugins and themes are lists of rows with a toggle each; a row opens the extension's own page with its settings, force-on, permissions, kept versions and removal - overview shows stat tiles, server facts and recent activity - old `/admin?tab=x` links redirect; the tab store is gone and the connector registry resolves admin links to the page URL - `NEXT_TURBOPACK_ROOT` overrides the Turbopack root so `next dev` runs from a worktree with a junctioned node_modules --- app/(main)/admin/[[...segments]]/page.tsx | 51 + .../marketplace-install-version.test.tsx | 2 +- .../_jmap-servers-section.tsx | 87 +- app/(main)/admin/_pages/auth.tsx | 324 +++++++ app/(main)/admin/_pages/branding.tsx | 639 +++++++++++++ app/(main)/admin/_pages/dashboard.tsx | 264 ++++++ app/(main)/admin/_pages/extension-detail.tsx | 490 ++++++++++ app/(main)/admin/_pages/logs.tsx | 167 ++++ .../admin/_pages/marketplace-detail.tsx | 838 ++++++++++++++++ .../admin/{_tabs => _pages}/marketplace.tsx | 581 ++++++------ .../admin/_pages/plugin-approvals-panel.tsx | 209 ++++ app/(main)/admin/_pages/plugins.tsx | 283 ++++++ app/(main)/admin/_pages/policy.tsx | 628 ++++++++++++ app/(main)/admin/_pages/settings.tsx | 192 ++++ app/(main)/admin/_pages/telemetry.tsx | 251 +++++ app/(main)/admin/_pages/themes.tsx | 401 ++++++++ app/(main)/admin/_pages/version.tsx | 213 +++++ app/(main)/admin/_tabs/auth.tsx | 423 --------- app/(main)/admin/_tabs/branding.tsx | 724 -------------- app/(main)/admin/_tabs/dashboard.tsx | 194 ---- app/(main)/admin/_tabs/logs.tsx | 174 ---- .../admin/_tabs/plugin-approvals-panel.tsx | 192 ---- .../admin/_tabs/plugin-config-panel.tsx | 332 ------- app/(main)/admin/_tabs/plugins.tsx | 507 ---------- app/(main)/admin/_tabs/policy.tsx | 625 ------------ app/(main)/admin/_tabs/settings.tsx | 300 ------ app/(main)/admin/_tabs/telemetry.tsx | 251 ----- app/(main)/admin/_tabs/themes.tsx | 632 ------------- app/(main)/admin/_tabs/version.tsx | 237 ----- app/(main)/admin/auth/page.tsx | 5 - app/(main)/admin/branding/page.tsx | 5 - app/(main)/admin/change-password/page.tsx | 99 +- app/(main)/admin/layout.tsx | 445 +-------- app/(main)/admin/login/page.tsx | 21 +- app/(main)/admin/logs/page.tsx | 5 - app/(main)/admin/marketplace/[slug]/page.tsx | 893 ------------------ app/(main)/admin/marketplace/page.tsx | 5 - app/(main)/admin/page.tsx | 49 - app/(main)/admin/plugins/[id]/page.tsx | 7 - app/(main)/admin/plugins/page.tsx | 5 - app/(main)/admin/policy/page.tsx | 5 - app/(main)/admin/settings/page.tsx | 5 - app/(main)/admin/telemetry/page.tsx | 5 - app/(main)/admin/themes/page.tsx | 5 - app/(main)/admin/version/page.tsx | 5 - .../admin/__tests__/flash-message.test.tsx | 31 + components/admin/__tests__/save-bar.test.tsx | 53 ++ components/admin/admin-link.tsx | 36 + components/admin/admin-shell.tsx | 519 ++++++++++ components/admin/badge.tsx | 34 + components/admin/button-link.tsx | 32 + components/admin/config-items.tsx | 148 +++ components/admin/empty-state.tsx | 23 + components/admin/flash-message.tsx | 57 ++ components/admin/list.tsx | 74 ++ components/admin/page-header.tsx | 50 + components/admin/save-bar.tsx | 70 ++ components/admin/shell-context.tsx | 16 + components/connector/connector-client.tsx | 11 +- hooks/use-admin-navigation.ts | 32 + lib/__tests__/connector-registry.test.ts | 11 +- lib/admin/__tests__/admin-nav.test.ts | 55 ++ lib/admin/admin-nav.ts | 234 +++++ lib/connector/registry.ts | 25 +- next.config.ts | 2 +- stores/admin-tab-store.ts | 41 - 66 files changed, 6816 insertions(+), 6513 deletions(-) create mode 100644 app/(main)/admin/[[...segments]]/page.tsx rename app/(main)/admin/{_tabs => _pages}/_jmap-servers-section.tsx (68%) create mode 100644 app/(main)/admin/_pages/auth.tsx create mode 100644 app/(main)/admin/_pages/branding.tsx create mode 100644 app/(main)/admin/_pages/dashboard.tsx create mode 100644 app/(main)/admin/_pages/extension-detail.tsx create mode 100644 app/(main)/admin/_pages/logs.tsx create mode 100644 app/(main)/admin/_pages/marketplace-detail.tsx rename app/(main)/admin/{_tabs => _pages}/marketplace.tsx (51%) create mode 100644 app/(main)/admin/_pages/plugin-approvals-panel.tsx create mode 100644 app/(main)/admin/_pages/plugins.tsx create mode 100644 app/(main)/admin/_pages/policy.tsx create mode 100644 app/(main)/admin/_pages/settings.tsx create mode 100644 app/(main)/admin/_pages/telemetry.tsx create mode 100644 app/(main)/admin/_pages/themes.tsx create mode 100644 app/(main)/admin/_pages/version.tsx delete mode 100644 app/(main)/admin/_tabs/auth.tsx delete mode 100644 app/(main)/admin/_tabs/branding.tsx delete mode 100644 app/(main)/admin/_tabs/dashboard.tsx delete mode 100644 app/(main)/admin/_tabs/logs.tsx delete mode 100644 app/(main)/admin/_tabs/plugin-approvals-panel.tsx delete mode 100644 app/(main)/admin/_tabs/plugin-config-panel.tsx delete mode 100644 app/(main)/admin/_tabs/plugins.tsx delete mode 100644 app/(main)/admin/_tabs/policy.tsx delete mode 100644 app/(main)/admin/_tabs/settings.tsx delete mode 100644 app/(main)/admin/_tabs/telemetry.tsx delete mode 100644 app/(main)/admin/_tabs/themes.tsx delete mode 100644 app/(main)/admin/_tabs/version.tsx delete mode 100644 app/(main)/admin/auth/page.tsx delete mode 100644 app/(main)/admin/branding/page.tsx delete mode 100644 app/(main)/admin/logs/page.tsx delete mode 100644 app/(main)/admin/marketplace/[slug]/page.tsx delete mode 100644 app/(main)/admin/marketplace/page.tsx delete mode 100644 app/(main)/admin/page.tsx delete mode 100644 app/(main)/admin/plugins/[id]/page.tsx delete mode 100644 app/(main)/admin/plugins/page.tsx delete mode 100644 app/(main)/admin/policy/page.tsx delete mode 100644 app/(main)/admin/settings/page.tsx delete mode 100644 app/(main)/admin/telemetry/page.tsx delete mode 100644 app/(main)/admin/themes/page.tsx delete mode 100644 app/(main)/admin/version/page.tsx create mode 100644 components/admin/__tests__/flash-message.test.tsx create mode 100644 components/admin/__tests__/save-bar.test.tsx create mode 100644 components/admin/admin-link.tsx create mode 100644 components/admin/admin-shell.tsx create mode 100644 components/admin/badge.tsx create mode 100644 components/admin/button-link.tsx create mode 100644 components/admin/config-items.tsx create mode 100644 components/admin/empty-state.tsx create mode 100644 components/admin/flash-message.tsx create mode 100644 components/admin/list.tsx create mode 100644 components/admin/page-header.tsx create mode 100644 components/admin/save-bar.tsx create mode 100644 components/admin/shell-context.tsx create mode 100644 hooks/use-admin-navigation.ts create mode 100644 lib/admin/__tests__/admin-nav.test.ts create mode 100644 lib/admin/admin-nav.ts delete mode 100644 stores/admin-tab-store.ts 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/_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..2f2b9f2f3 --- /dev/null +++ b/app/(main)/admin/_pages/extension-detail.tsx @@ -0,0 +1,490 @@ +'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 { cn } from '@/lib/utils'; + +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 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)} /> + +
+ {kind === 'plugin' && hasSchema && schema && ( + + {Object.entries(schema).map(([key, field]) => ( + + setRevealSecrets((prev) => ({ ...prev, [key]: !prev[key] }))} + onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))} + /> + + ))} + + )} + + {kind === 'plugin' && } + + + + 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) => ( + + + + ))} + +
+ + 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) && ( -