diff --git a/.dockerignore b/.dockerignore
index b6764a50f..68da62e5b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,6 +9,9 @@ lite-image
!.env.dev.example
scripts/
!scripts/lite/
+!scripts/build-plugin-sandbox.mjs
+# Built in the image by scripts/build-plugin-sandbox.mjs
+public/plugin-sandbox-assets/
TODO.md
*.md
!README.md
diff --git a/.env.example b/.env.example
index 62f5e4769..36a3c2002 100644
--- a/.env.example
+++ b/.env.example
@@ -30,7 +30,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Prefer configuring this from the admin dashboard - the env form exists for
# stateless deployments. An "oauth" block with both "clientId" and
# "buttonLabel" gives that server its own sign-in button on the login page,
-# even while OAuth is off globally.
+# even while OAuth is off globally. "endSessionUrl" in the "oauth" block is
+# that server's OAUTH_END_SESSION_URL (see below).
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
# Pick the server automatically from the domain of the address the user types,
@@ -80,10 +81,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
# Signing out of an SSO account also signs out of the identity provider,
-# through the end_session_endpoint it advertises (RP-initiated logout). Set to
-# false when the provider is shared with apps that should stay signed in.
+# through the end_session_endpoint it advertises or OAUTH_END_SESSION_URL
+# (RP-initiated logout). Set to false when the provider is shared with apps
+# that should stay signed in.
# OAUTH_END_SESSION=false
+# The provider's logout URL, for providers whose discovery document has no
+# end_session_endpoint (Stalwart's built-in provider, for example). When set,
+# it is used instead of the discovered endpoint and gets the same parameters:
+# client_id, id_token_hint and post_logout_redirect_uri. Must be https.
+# Applies to OAUTH_ISSUER_URL's provider only; JMAP_SERVERS entries set their
+# own "endSessionUrl" in their "oauth" block.
+# OAUTH_END_SESSION_URL=https://your-idp.example.com/logout
+
# Where the identity provider sends the browser after signing the user out.
# Register it with the provider as a post-logout redirect URI first, or it
# will refuse the logout. Unset: the provider shows its own signed-out page.
@@ -418,8 +428,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# COOKIE_SAME_SITE=none. Set to false only for local HTTP development.
# COOKIE_SECURE=false
-# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none',
-# which blocks all framing. Space-separate multiple origins.
+# Which other sites may frame the app, as a CSP frame-ancestors value. By
+# default none may. Space-separate multiple origins. 'self' is always added,
+# since the app frames its own PDF previews.
# ALLOWED_FRAME_ANCESTORS=https://portal.example.com
# Origin of the parent page when embedded, used for postMessage handshakes.
diff --git a/.gitattributes b/.gitattributes
index 2b543109f..c9944983b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,6 @@
# Sourced by /bin/sh inside the Lite container image: CRLF would break it.
*.envsh text eol=lf
+# Run by sh from Git Bash on Windows, which does not strip CR.
+.husky/* text eol=lf
+# Demo fixtures record exact byte sizes of these files; never normalise them.
+public/demo/** -text
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index a7bae41ac..d714a6a87 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -21,6 +21,10 @@ on:
- ".github/workflows/docker-publish.yml"
workflow_dispatch:
+# Jobs that need more (the registry push, the attestation) say so themselves.
+permissions:
+ contents: read
+
jobs:
prepare:
name: Prepare
diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml
index 35ecb88ee..4336d783c 100644
--- a/.github/workflows/perf-guard.yml
+++ b/.github/workflows/perf-guard.yml
@@ -9,6 +9,11 @@ on:
push:
pull_request:
+# The job builds and runs the checked-out code; a compromised step must not
+# hold a token that can write to the repository.
+permissions:
+ contents: read
+
jobs:
perf-guard:
name: Perf guard
@@ -36,7 +41,7 @@ jobs:
DEV_MOCK_JMAP: "true"
NEXT_TELEMETRY_DISABLED: "1"
GIT_COMMIT: ${{ github.sha }}
- run: npx next build --webpack
+ run: node scripts/build-plugin-sandbox.mjs && npx next build --webpack
- name: Run perf guard
env:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 43a7acac3..002bd8a76 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,6 +4,11 @@ on:
push:
pull_request:
+# The job builds and runs the checked-out code; a compromised step must not
+# hold a token that can write to the repository.
+permissions:
+ contents: read
+
jobs:
vitest:
name: Vitest
@@ -29,3 +34,28 @@ jobs:
# from timing out on the shared runners; the longer per-test timeout
# covers the jsdom-heavy suites.
run: npx vitest run --pool=forks --maxWorkers=2 --testTimeout=15000
+
+ # The pre-commit hook lints only staged files and never runs on fork PRs,
+ # so this is the one place the whole tree is checked.
+ lint:
+ name: Lint and typecheck
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Lint
+ run: npm run lint
diff --git a/.gitignore b/.gitignore
index 276b85604..dd9bc4281 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,8 @@
/.next/
/out/
/lite-image/
+# scripts/build-plugin-sandbox.mjs
+/public/plugin-sandbox-assets/
# production
/build
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 58f8aa205..5b09e6120 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,6 @@
-npx tsc --noEmit && npx eslint . --ext .ts,.tsx
+#!/bin/sh
+# Typecheck the project (incremental, a few seconds warm) and lint only the
+# staged files. The full lint and typecheck run in CI for every push and PR.
+npx tsc --noEmit || exit 1
+files=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx' '*.js' '*.mjs')
+[ -z "$files" ] || echo "$files" | xargs npx eslint --no-warn-ignored
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 642b7147e..bc8b88c90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,317 @@
# Changelog
+## 1.17.2 (2026-10-24)
+
+1.17.2 fixes a cross-site scripting hole in re-opened drafts and keeps Safari from dropping push subscriptions after pushes that had nothing new to show. Drafts written as a group identity now stay in the group's Drafts, and bulk delete, trash and move with several accounts on different servers act on the right server. Plugins can update and remove their own tags, format dates in the user's regional settings and put buttons beside a sidebar section's title. User-facing texts use the configured app name instead of "Bulwark".
+
+Thank you for your donations:
+
+- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
+
+**One-time**
+
+- [@bultester](https://github.com/bultester)
+- Anonymous
+- Anonymous
+- Anonymous
+
+**Monthly**
+
+- Anonymous
+- [@superspacehosting](https://github.com/superspacehosting)
+- [@naokton](https://github.com/naokton)
+- [@yerTools](https://github.com/yerTools)
+- [@jsaathof](https://github.com/jsaathof)
+- [@berkersal](https://github.com/berkersal)
+- [@NABarnes](https://github.com/NABarnes)
+- [@felixzieger](https://github.com/felixzieger)
+- [@pr0ton11](https://github.com/pr0ton11)
+- [@fpauser](https://github.com/fpauser)
+- [@proxforge](https://github.com/proxforge)
+- [@spss20](https://github.com/spss20)
+- [@elgringoYan](https://github.com/elgringoYan)
+- [@pauladams8](https://github.com/pauladams8)
+- [@djpriest](https://github.com/djpriest)
+- [@umakers](https://github.com/umakers)
+- [@zplizzi](https://github.com/zplizzi)
+- [@jeremiah](https://github.com/jeremiah)
+- [@Theoretisch1337](https://github.com/Theoretisch1337)
+- [@svandive](https://github.com/svandive)
+- [@HiltMundell](https://github.com/HiltMundell)
+
+### Security
+
+- **Composer**: The quoted original and the signature in a re-opened draft are sanitized before they render. A draft written by another client, or by another member of a shared mailbox, could carry markup that ran script in the webmail when you opened it (GHSA-g35q-3gpc-x2rr, thanks @muhammad-zeeshan1337)
+
+### Features
+
+- **Plugins**: Plugins can change the label, colour and visibility of the tags they added and remove them again with `keywords.update` and `keywords.remove`. Both need `settings:write` and never touch the user's own tags or another plugin's (#1333, thanks @HOLOEYE-JZ)
+- **Plugins**: Plugin frames get the user's date locale, 12/24-hour setting, time zone and first day of the week, and can format with `api.i18n.formatDate` and `api.i18n.formatTime`, without asking for `settings:read` (#1335, thanks @tobiasge)
+- **Plugins**: A new `sidebar-section-actions` slot puts a plugin's buttons beside its sidebar section's title instead of in a row below it (#1317, thanks @LeonardNJU)
+- **Admin**: A policy switch lists the Tags above the shared mailboxes in the sidebar, so they stay in sight when many shared mailboxes are expanded. It is off by default (#1334, thanks @HOLOEYE-JZ)
+- **Branding**: Default apps in Settings, the "Open Bulwark" notification, the rate-limit banner, the plugin connect dialog and the connector page use the configured `APP_NAME`, and the settings search finds them by it (#1314, thanks @ghozty)
+
+### Fixes
+
+- **Push**: Every push ends with a notification, also one that brings nothing new (a redelivery, mail already read or already announced), and a slow preview gives way to the generic notification after 10 seconds. Safari treats a push without a notification as silent and drops the subscription after three (#1322, thanks @oguzyilmaznet)
+- **Mail**: A draft written as a group identity is saved in the group account's Drafts, where its Sent copy also goes, instead of in your own (#1090, thanks @hildebrandttk)
+- **Mail**: A sub-address of your own address (you+tag@) sends from your account, even when a shared catch-all account has a wildcard identity for the domain
+- **Mail**: Bulk delete, trash and move send each message through the login that owns it. With accounts on two servers that use the same account id, they went through the first login and could hit an unselected message on the other server (#1307, #1326, thanks @VasilisNtovolos)
+- **Mail**: Dragging several messages out as a ZIP fails with a message when one of them can't be fetched, instead of leaving it out, and the same selection can be tried again (#1306, thanks @VasilisNtovolos)
+- **Tasks**: A task alert relative to the start fires from the task's start instead of its due date. A task without a start still counts from its due date (#1328, thanks @ClapsAndJiggles)
+- **Plugins**: Plugins installed by the admin are listed in browsers that can't cache their code, such as some private windows (#1327, thanks @ovizii)
+- **i18n**: The Ukrainian "Forward" and "Move to" are translated as the actions, not as "ahead" and "go to" (#1311, thanks @vladonv)
+
+## 1.17.1 (2026-10-22)
+
+### Security
+
+- **Dependencies**: Next.js 16.4.2 and DOMPurify 3.4.17 fix six published advisories, three of them critical. None of them reached Bulwark: the webmail has no `use cache` functions and no Server Actions, its image optimizer refuses SVG and runs on sharp 0.35.5, which already carries the fix, and the mail sanitizer uses none of the DOMPurify modes the bypasses need (SAFE_FOR_TEMPLATES, IN_PLACE, XHTML parsing). Updating is still recommended
+
+1.17.1 stops the background-notifications prompt from asking you to turn push on while the app is still restoring a subscription the browser dropped, and an open tab now repairs a dropped subscription on its own. The message list stays within the screen on phones, and a browser that was already signed in hands a mobile sign-in to the app again.
+
+Thank you for your donations:
+
+- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
+
+**One-time**
+
+- [@bultester](https://github.com/bultester)
+- Anonymous
+- Anonymous
+- Anonymous
+
+**Monthly**
+
+- [@superspacehosting](https://github.com/superspacehosting)
+- [@naokton](https://github.com/naokton)
+- [@yerTools](https://github.com/yerTools)
+- [@jsaathof](https://github.com/jsaathof)
+- [@berkersal](https://github.com/berkersal)
+- [@NABarnes](https://github.com/NABarnes)
+- [@felixzieger](https://github.com/felixzieger)
+- [@pr0ton11](https://github.com/pr0ton11)
+- [@fpauser](https://github.com/fpauser)
+- [@proxforge](https://github.com/proxforge)
+- [@spss20](https://github.com/spss20)
+- [@elgringoYan](https://github.com/elgringoYan)
+- [@pauladams8](https://github.com/pauladams8)
+- [@djpriest](https://github.com/djpriest)
+- [@umakers](https://github.com/umakers)
+- [@zplizzi](https://github.com/zplizzi)
+- [@jeremiah](https://github.com/jeremiah)
+- [@Theoretisch1337](https://github.com/Theoretisch1337)
+- [@svandive](https://github.com/svandive)
+- [@HiltMundell](https://github.com/HiltMundell)
+
+### Changes
+
+- **Dependencies**: vitest 4.1.12 and vite 8.3.5 (npm audit)
+
+### Fixes
+
+- **Push**: The background-notifications prompt waits for the background re-sync and only asks if push is still off afterwards, instead of appearing while the app is restoring a subscription Safari dropped (#1321, thanks @oguzyilmaznet)
+- **Push**: A tab that stays open notices when the browser drops or replaces its push subscription and registers the new one at once, and coming back to the tab recreates a lost subscription instead of waiting for the next reload (#1321)
+- **Mobile**: On phones, the message list is never wider than the screen, so the header's buttons, each row's date and the subject's ellipsis stay on screen and the selection toolbar compacts its buttons
+- **Login**: A mobile sign-in opened in a browser that was already signed in keeps the sign-in form and hands the sign-in to the app, instead of opening the mailbox and leaving the app stuck in its sign-in sheet
+
+## 1.17.0 (2026-10-21)
+
+1.17.0 gives plugins real panels: a panel next to the message you write or read can fill its height, start folded, be opened by the plugin, and be dragged wider, and plugins get right-click menus on selected text in the composer and in a message. Plugins can also search mail and the settings, read the user's preferences and the app's version. The AI Writing Assistant in the marketplace builds on this and needs this release. The reader's side panel stays open from message to message, and the admin panel is reworked around a settings-style sidebar with one URL per page. The Docker images move to Node.js 24.22.1 for a Node.js security fix.
+
+Thank you for your donations:
+
+- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
+
+**One-time**
+
+- [@bultester](https://github.com/bultester)
+- Anonymous
+- Anonymous
+- Anonymous
+
+**Monthly**
+
+- [@superspacehosting](https://github.com/superspacehosting)
+- [@naokton](https://github.com/naokton)
+- [@yerTools](https://github.com/yerTools)
+- [@jsaathof](https://github.com/jsaathof)
+- [@berkersal](https://github.com/berkersal)
+- [@NABarnes](https://github.com/NABarnes)
+- [@felixzieger](https://github.com/felixzieger)
+- [@pr0ton11](https://github.com/pr0ton11)
+- [@fpauser](https://github.com/fpauser)
+- [@proxforge](https://github.com/proxforge)
+- [@spss20](https://github.com/spss20)
+- [@elgringoYan](https://github.com/elgringoYan)
+- [@pauladams8](https://github.com/pauladams8)
+- [@djpriest](https://github.com/djpriest)
+- [@umakers](https://github.com/umakers)
+- [@zplizzi](https://github.com/zplizzi)
+- [@jeremiah](https://github.com/jeremiah)
+- [@Theoretisch1337](https://github.com/Theoretisch1337)
+- [@svandive](https://github.com/svandive)
+- [@HiltMundell](https://github.com/HiltMundell)
+
+### Security
+
+- **Server**: The Docker images are built on Node.js 24.22.1, which fixes an out-of-bounds write in the Brotli decoder (CVE-2026-111497). The server decodes Brotli responses when it fetches a calendar subscription, a WebDAV share or a plugin request, so a signed-in user could point it at a host that sends a crafted response and run code on the server. The same Node.js release fixes TLS hostname checks for servers addressed by IP. Standalone installs run on the system's Node.js: update it to 22.23.4, 24.22.1 or 26.12.1
+
+### Features
+
+- **Plugins**: A panel slot (`composer-sidebar`, `composer-sidebar-right`, `email-detail-sidebar`) can fill its panel and scroll inside (`fill`), start folded until the user or the plugin opens it (`startFolded`), and be opened, closed and toggled by the plugin with `api.ui.openPanel`, `closePanel` and `togglePanel`, which answer null where no panel is on screen (phones), so a plugin can fall back to a dialog. A panel opened this way gets a `focusRequest` prop
+- **Plugins**: The composer's plugin panels get the composer id, the draft and the selected text as it changes; the reader's side panel gets the selected text; reader frames learn whether they are on a phone, a tablet or a desktop (`deviceType`)
+- **Plugins**: Right-click menus on selected text: `composer-selection-menu` in the message being written and `reader-selection-menu` in an open message. The browser's own menu stays until a plugin adds entries; the webmail then draws Cut and Copy and the plugin's entries, and the click carries the selected text
+- **Plugins**: Text selected inside a message's body (an HTML mail in its frame) now reaches `onTextSelectionChange`
+- **Plugins**: Plugins can search mail with `api.email.search`, search the settings and read the user's preferences, and read the app's name and version with `api.app.getInfo`
+- **Mail**: The reader's side panel stays open when you move to another message, and its toggle sits in the reader toolbar, which now runs over the side panels to the right edge
+- **Composer**: The plugin panels beside the composer can be dragged wider or narrower, and keep their width
+- **Admin**: The admin panel is reworked around a settings-style sidebar with one URL per page, the real navigation rail and extension icons
+
+### Fixes
+
+- **Mail**: Rescheduling a scheduled message cancels the original send before creating the new one, so it works on servers that allow one active submission per message (#1308)
+- **Mail**: Dragging messages out of the list and downloading attachments, inline images and the raw message in the Pro view use the account that owns the message, so shared and secondary-account messages no longer fail (#1299, #1300, thanks @VasilisNtovolos)
+- **Mail**: Deleting a message from its hover actions shows the actions on the next row that slides under the pointer, so you can delete one after another from the same spot (#1297, thanks @dealerweb)
+- **Mail**: PDF attachment previews open in Safari (#1301)
+- **Mail**: The contact card for a sender or recipient opens in place instead of sliding in from the top left
+- **Plugins**: A plugin's passphrase dialog stays open while you type (#1302)
+- **Plugins**: The push preference mirror no longer throws in sandboxed plugin frames (#1298, thanks @dealerweb)
+- **Layout**: A pane divider stops following the mouse when the button is released over a frame
+- **i18n**: The German dialog that hands a sign-in to the mobile app is translated (#1296, thanks @dealerweb)
+
+## 1.16.0 (2026-10-19)
+
+1.16.0 regroups the settings into fewer pages with an Advanced switch for the power-user options, shows the message list as columns on request, turns @mentions into contact chips, and opens the company directory in contacts and the composer. The marketplace can install any version of an extension and roll back to a replaced one, admins get policies for the colour mode, archiving and the provider sign-out, and the Estonian locale arrives. A security audit of the whole app fixed cross-account actions on repeated message ids and about thirty smaller findings.
+
+Thank you for your donations:
+
+- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
+
+**One-time**
+
+- [@bultester](https://github.com/bultester)
+- Anonymous
+- Anonymous
+- Anonymous
+
+**Monthly**
+
+- [@superspacehosting](https://github.com/superspacehosting)
+- [@naokton](https://github.com/naokton)
+- [@yerTools](https://github.com/yerTools)
+- [@jsaathof](https://github.com/jsaathof)
+- [@berkersal](https://github.com/berkersal)
+- [@NABarnes](https://github.com/NABarnes)
+- [@felixzieger](https://github.com/felixzieger)
+- [@pr0ton11](https://github.com/pr0ton11)
+- [@fpauser](https://github.com/fpauser)
+- [@proxforge](https://github.com/proxforge)
+- [@spss20](https://github.com/spss20)
+- [@elgringoYan](https://github.com/elgringoYan)
+- [@pauladams8](https://github.com/pauladams8)
+- [@djpriest](https://github.com/djpriest)
+- [@umakers](https://github.com/umakers)
+- [@zplizzi](https://github.com/zplizzi)
+- [@jeremiah](https://github.com/jeremiah)
+- [@Theoretisch1337](https://github.com/Theoretisch1337)
+- [@svandive](https://github.com/svandive)
+- [@HiltMundell](https://github.com/HiltMundell)
+
+### Security
+
+- **Mail**: Delete, move and drag in merged views and cross-account search act on the clicked message. When two accounts had messages with the same id, the action could hit the other account's message, which also made sent mail vanish from the Sent folder (#857, GHSA-8x23-qw3w-mhrx, thanks @FeliceBalsamo and @knittingcode)
+- **Mail**: HTML attachments preview as sanitised text in a locked frame. The preview used to open the sender's HTML as a same-origin document that could run script in the webmail origin
+- **Mail**: A crafted style attribute, read-receipt header, mail body or subject no longer freezes the reader or the message list
+- **Mail**: Only an SPF or DKIM pass aligned with the From domain counts as sender verification, and oversized winmail.dat and embedded message parts stay attachments instead of being decoded on open
+- **Mail**: Attachment names show without directional override characters, and imported and synced HTML templates are sanitised before the composer previews them
+- **Login**: The login page asks before handing a sign-in to the mobile app, and only hands it to the app's own callback link
+- **Login**: Failed sign-ins are capped per client at 5 in 15 minutes, and the client address is forwarded to the mail server as X-Forwarded-For so the server's own limits hit the client instead of the webmail. Add the webmail to Stalwart's server.proxy.trusted-networks for that to take effect
+- **Login**: Synced settings are keyed on the account a token login resolves to, not on the claimed name, so two users can no longer share a settings blob
+- **Admin**: OAuth client secrets and the session secret stay out of the admin audit log, the config API and dashboard writes
+- **Admin**: OAuth discovery no longer follows a redirect to another host, the extension directory URL cannot point at a private address, a directory keyring older than the newest one seen is refused, marketplace versions are validated and a silent downgrade on update is refused, and the installer writes its env file readable by its owner only
+- **Plugins**: Plugin POSTs stay off the plugin proxy, secrets, agenda, ical and translate routes, only a plugin known to the server can connect and use credentials, plugin-authored HTML drops remote images, and a plugin frame that navigated to another document gets no more host messages
+- **Server**: TLS responses carry HSTS and the X-Powered-By header is gone. Upstream mail server requests get a deadline, CalDAV discovery probes and concurrent favicon fetches are bounded, and calendar feeds, synced settings blobs and the settings route have size caps
+
+### Features
+
+- **Settings**: The settings are regrouped into Account, Security, Notifications, General and Appearance, the mail pages Message list, Reading, Composing, Folders & tags, Filters and Out of office, then Calendar, Contacts, Files and Add-ons with the plugin pages under it, and Backup & reset. Every page has a jump row to its sections, settings links can name a section, and old settings links keep working
+- **Settings**: An Advanced switch hides power-user settings until turned on. Search results and section links that point at a hidden setting turn it on
+- **Settings**: Themes show as one row with a Show all button, and the settings search box looks like the mail search box
+- **UI**: The message list can show as columns with one-line conversation rows (#262, thanks @fdisamuel)
+- **Composer**: Typing @ offers contacts and adds the picked one as a recipient. Mentions become chips with a contact card on hover, in the composer and in received mail
+- **Contacts**: The company directory can be browsed in contacts and the composer (#1253, thanks @diegonix)
+- **Mail**: "Remove from this folder" takes a message out of just the open folder when it is in several (#615, thanks @arnauda-gh)
+- **Mail**: n jumps to the next unread message (#514, thanks @muelli)
+- **Mail**: Shift+arrow keys extend the selection (#992, thanks @IntExCZ), and while selecting, a click anywhere on a row ticks it (#1014, thanks @mmomjian)
+- **Mail**: Search results from all folders show the folder (#1172, thanks @PoltoS)
+- **Mail**: Your own folders are listed in a separate section of the sidebar (#1205, thanks @roedy-dev)
+- **Mail**: The counter can be set per folder, e.g. keep the Drafts count with totals off (#982, thanks @mbjboon-netizen)
+- **Mail**: The account dot in the unified view can be hidden (#912, thanks @mmomjian)
+- **Mail**: Sender icons are refreshed when they changed on the sender's website (#960, thanks @IntExCZ)
+- **Composer**: Message priority can be set in the composer (#970, thanks @douwezijlstra-frl)
+- **Composer**: Templates get numeric date, day, month and year placeholders (#1284, thanks @Yahatix)
+- **Accounts**: When the admin turns on "Account List Backup: Passwords", saving the account list offers to include the passwords of accounts signed in with a plain password, encrypted in the browser. Restoring signs those accounts in in the background (#1247, thanks @lucamzanon)
+- **Admin**: The theme policy can set and lock the colour mode (#1280, thanks @chodid)
+- **Admin**: A configured logout URL ends the provider session for providers whose discovery has no end_session_endpoint (#1279, thanks @chodid)
+- **Admin**: A policy gate turns archiving off (#1257, #1258, thanks @HOLOEYE-JZ)
+- **Admin**: The admin panel asks before leaving with unsaved changes (#1255, thanks @passibe15)
+- **Admin**: The version is hidden from the connector probe when the login page hides it (#896, thanks @x86dev)
+- **Marketplace**: Any version of an extension can be installed. The detail page lists every version, previews the picked one and warns about withdrawn versions, advisories, deprecations and suspensions, the listing can be sorted and filtered to installed extensions with an updates strip, and an install link can name the version to offer
+- **Marketplace**: Replaced plugin and theme versions are kept and can be rolled back to
+- **Plugins**: The UI kit gets a Combobox (#1251, thanks @tobiasge)
+- **Translations**: Estonian is a new locale, including the company directory and the strings added since (#1254, thanks @raudraido)
+- **Demo**: The demo follows one persona with a work account, a personal account and a shared Support mailbox, with real headers, an invitation, mentions, tags, templates and files
+
+### Changes
+
+- **Marketplace**: The marketplace talks to the extension directory's v2 API only. A self-hosted directory older than 2026-10-14 no longer works with it
+- **Plugins**: A user credential now exists only for a registry plugin or an admin-approved upload whose manifest declares it. Approvals made before this version need a fresh approval request before the plugin can use credentials
+- **UI**: The whole app uses the theme's sans font (#1278, thanks @chodid)
+- **UI**: The app switcher uses simpler calendar, contacts and files icons, and the calendar, contacts, files and settings sidebar headers have the mail header's height
+- **Performance**: Starting a search no longer re-renders every row
+
+### Fixes
+
+- **Mail**: Push notifications can be enabled for an account with access to a shared folder (#1050, thanks @magnetic6)
+- **Mail**: The list shows the mail's own date and keeps it on .eml import (#891, thanks @johnnyq)
+- **Mail**: PGP and S/MIME messages inside conversations are decrypted (#902, thanks @rajil)
+- **Mail**: Attachments preview in the installed app instead of opening a blank window (#865, thanks @navidrast)
+- **Mail**: Message previews drop plain-text emphasis markers (#1260, thanks @mmomjian)
+- **Mail**: Today's and yesterday's mail are told apart by the time zone setting in the list and the sender line (#1259, #1262, thanks @mmomjian and @balabommablock-cpu)
+- **Mail**: The category tab filter is kept when the mailbox refreshes, and the whole folder comes back after a restart when a category tab was open (#1264, #1265, thanks @alyyousuf7)
+- **Mail**: Shared folders from other servers are kept when they reuse an account id, and a shared folder reachable through two logins is listed once in cross views (#1108, thanks @hildebrandttk)
+- **Mail**: Renaming a tag follows it into the filter rules that name it, and keeps vacation forwarding and plugin vetoes while doing so (#854, thanks @dev-hive-kazniisa)
+- **Mail**: Bulk archive across accounts is routed by the owning account, and Archive is only created where the account allows it (#1178, thanks @VasilisNtovolos)
+- **Mail**: Conversation blobs are fetched from the message they belong to, and file previews stay open in development Strict Mode (#1287, #1288, thanks @VasilisNtovolos)
+- **Mail**: A shared conversation is marked read on the owner account, and the mark-as-read delay is followed when expanding a conversation or opening a Pro folder row (#1272, thanks @Peterka35)
+- **Mail**: The reader's Move, Tag and Rules flyouts open on the side with room (#1261, thanks @beja19)
+- **Mail**: Vacation filter rules are skipped on shared accounts without Sieve (#1263, thanks @somus)
+- **Mail**: Nextcloud filter markers inside rule text are ignored
+- **Composer**: A reply to a message from another connected account is sent from that account to the right recipient (#703, #1270, thanks @jesperordrup and @vixh)
+- **Composer**: Replies whose From is picked after the composer opens are signed (#842, thanks @mmomjian)
+- **Composer**: A contact group whose member is not an address is no longer sent (#795, thanks @brambuijs)
+- **Composer**: Your own address stays out of reply-all before your identities have loaded (#803, thanks @lucletoffe)
+- **Composer**: Sent mail keeps blank lines instead of collapsing them (#1015, thanks @mbjboon-netizen)
+- **Composer**: Inline images in replies and forwards are attached once (#1125, thanks @guisea)
+- **Composer**: The quote containment styles stay in the editor and out of the sent mail, and a quoted original can no longer paint over the composer
+- **Composer**: The composer opens when the mail client can't report directory support
+- **Composer**: A click on a mention chip opens the contact card instead of the mailto handler in Firefox
+- **Calendar**: Zero-length events end at their start instead of midnight (#837, thanks @Flying-Bas)
+- **Calendar**: Invitation times show in your time zone (#1252, thanks @TomekCodeWave)
+- **Calendar**: Answering an invite sent to a non-primary identity works (#1283, thanks @taranasus)
+- **Calendar**: The visible range is refetched after creating a recurring event (#1268, thanks @sanitz)
+- **Calendar**: Month view day columns align with the header (#1286, thanks @tobiasge)
+- **Tasks**: An all-day task's due and start are sent as a LocalDateTime (#1256, thanks @sanitz)
+- **Notifications**: Push notifications stay silent when the notification sound is off (#1289, thanks @chreds)
+- **Admin**: The admin panel loads when the config is read-only, policy defaults are applied to user settings, and OAuth auto-setup is refused before touching Stalwart when the config is read-only (#1248, #1249, #1250, thanks @chodid)
+- **Accounts**: Restored sign-ins are remembered only when the restoring account is remembered, and contact and calendar data that arrives after switching accounts is dropped instead of landing in the other account
+- **Plugins**: api.email.get reads a listed message of a shared account and no longer reads mail of other connected logins (#1273, #1274, thanks @alyyousuf7)
+- **Plugins**: A plugin slot's callbacks keep working when its props change while it loads (#1266, thanks @dedoitalia)
+- **Plugins**: Plugin dropdown messages are checked before the host draws them
+- **Lite**: Demo portraits load from the mount prefix so a bundle under a subpath stays self-contained, and the dev server stays inside its export directory
+- **Translations**: German strings that still showed in English (#1285, thanks @dealerweb), contact field types and more French strings
+
## 1.15.0 (2026-10-15)
1.15.0 opens the plugin system up: plugins can add UI in most places of the app, hear and stop most actions, and use accounts and API keys through the server without seeing them, and users can install their own plugins once an admin has reviewed the code. Opening folders and messages is faster, with far fewer re-renders and requests. Calendars get default reminders, notifications can be muted per folder or account, and every sign-in is now remembered unless the admin shows the Remember me box.
diff --git a/Dockerfile b/Dockerfile
index c9efc1cfa..f41759013 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,6 +30,7 @@ RUN rm -rf "app/api/dev-jmap" && \
find . -path ./node_modules -prune -o \
\( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \
-prune -exec rm -rf {} \; && \
+ node scripts/build-plugin-sandbox.mjs && \
npx next build --webpack
FROM node:24-alpine AS runner
diff --git a/README.md b/README.md
index 930072229..541deac73 100644
--- a/README.md
+++ b/README.md
@@ -40,16 +40,16 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https
-
+
Calendar week view · Contact details with recent mail and upcoming events
-
+
- Composer with identities and formatting · Light and dark themes
+ Composer with @mention cards · Light and dark themes
## Features
diff --git a/VERSION b/VERSION
index 141f2e805..06fb41b63 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.15.0
+1.17.2
diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx
index 380ac321d..28a75200c 100644
--- a/app/(main)/[locale]/auth/callback/page.tsx
+++ b/app/(main)/[locale]/auth/callback/page.tsx
@@ -10,6 +10,8 @@ import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-o
import { Loader2, AlertCircle } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
+import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
+import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
function OAuthCallbackInner() {
const router = useRouter();
@@ -18,6 +20,9 @@ function OAuthCallbackInner() {
const t = useTranslations("login");
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
const [error, setError] = useState(null);
+ // A sign-in started for the mobile app, waiting for the user to confirm
+ // the handoff (lib/auth/mobile-handoff.ts).
+ const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null);
// The authorization code is single-use. React's development double effect
// ran this twice: the second pass no longer saw the pairing re-auth flag
// (the first had removed it) and spent the code on the login flow instead.
@@ -135,14 +140,14 @@ function OAuthCallbackInner() {
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
// Branch on mobile handoff first: the login page left a marker in
// sessionStorage if it kicked this OAuth dance off for the mobile app.
- let mobileRedirectUri: string | null = null;
+ let mobileRedirectUri = "";
let mobileState: string | null = null;
try {
- mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
+ mobileRedirectUri = mobileHandoffTarget(sessionStorage.getItem("mobile_redirect_uri"));
mobileState = sessionStorage.getItem("mobile_state");
} catch { /* sessionStorage may be unavailable */ }
- if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
+ if (mobileRedirectUri) {
// Drive /api/auth/sso/complete directly so we can read the tokens
// out of the response - loginWithServerSso would consume them and
// wire up the webmail auth store, which isn't useful here. The
@@ -187,7 +192,12 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("mobile_redirect_uri");
sessionStorage.removeItem("mobile_state");
} catch { /* ignore */ }
- window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
+ // Shown to the user first; the navigation happens on their confirmation.
+ setPendingHandoff({
+ url: buildMobileHandoffUrl(mobileRedirectUri, Object.fromEntries(fragment)),
+ // The complete route names the account's server, not the user.
+ username: typeof data.username === "string" ? data.username : serverUrl,
+ });
} catch {
setError("token_exchange_failed");
}
@@ -220,6 +230,21 @@ function OAuthCallbackInner() {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
+ if (pendingHandoff) {
+ return (
+
+ {
+ setPendingHandoff(null);
+ router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`));
+ }}
+ />
+
+ );
+ }
+
if (error) {
return (
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index a4ee53ec8..1aa5b7c3f 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { useAuthStore, consumeSignedOut } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
+import { usePolicyStore } from "@/stores/policy-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
@@ -31,6 +32,8 @@ import {
} from "@/lib/auth/lite-oauth";
import { toAsciiDomain } from "@/lib/idn";
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
+import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
+import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
function completeAddressDomain(username: string): string {
@@ -133,11 +136,6 @@ function VersionBadge() {
);
}
-// Only redirect targets matching this scheme are honored by the mobile
-// handoff path. Without the check the login page becomes an open redirector
-// that funnels password and token material to any caller-supplied URL.
-const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
-
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
@@ -149,18 +147,19 @@ function LoginPageContent() {
// these params. We grab them once at mount and stash them in a ref so any
// login path that completes (password or OAuth) can hand control back to
// the app instead of routing into /mail.
- const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
+ // Only the app's own callback link is a handoff target, and nothing is
+ // handed over before the user confirms it (lib/auth/mobile-handoff.ts).
const rawMobileState = searchParams.get("mobile_state") ?? "";
- const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
- ? rawMobileRedirectUri
- : "";
+ const mobileRedirectUri = mobileHandoffTarget(searchParams.get("mobile_redirect_uri"));
const mobileState = mobileRedirectUri ? rawMobileState : "";
const isMobileHandoff = Boolean(mobileRedirectUri);
+ const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null);
usePrefetchMailView(!isMobileHandoff);
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
+ const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
// Login logo sizing: when a max height/width is configured, drop the fixed
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
@@ -365,21 +364,39 @@ function LoginPageContent() {
}
}, [serverUrl]);
+ // Mobile handoff: hand the verified credentials back to the mobile app
+ // instead of opening the mailbox. handleSubmit captured the values needed
+ // for the fragment. Runs once per sign-in: whichever of the effect below
+ // and handleSubmit gets here first takes the payload.
+ const offerMobileHandoff = useCallback(() => {
+ const payload = mobileHandoffPayloadRef.current;
+ if (!payload) return;
+ mobileHandoffPayloadRef.current = null;
+ // Shown to the user first; the navigation happens on their confirmation.
+ setPendingHandoff({
+ url: buildMobileHandoffUrl(mobileRedirectUri, { flow: "password", ...payload, state: mobileState }),
+ username: payload.username,
+ });
+ }, [mobileRedirectUri, mobileState]);
+
useEffect(() => {
- if (isAuthenticated && !isAddAccountMode) {
- // Mobile handoff: the password path completes here once the auth store
- // flips isAuthenticated. Hand the verified credentials back to the
- // mobile app instead of pushing to /mail. handleSubmit captured the
- // values needed for the fragment.
- if (isMobileHandoff && mobileHandoffPayloadRef.current) {
- const fragment = new URLSearchParams({
- flow: "password",
- ...mobileHandoffPayloadRef.current,
- state: mobileState,
- });
- window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
- return;
- }
+ if (!isAuthenticated) return;
+ const action = signedInLoginAction({
+ isMobileHandoff,
+ isAddAccountMode,
+ hasHandoffPayload: mobileHandoffPayloadRef.current !== null,
+ });
+ if (action === "handoff") {
+ // The password path completes here once the auth store flips
+ // isAuthenticated.
+ offerMobileHandoff();
+ return;
+ }
+ // A browser that was already signed in keeps the form: the app needs a
+ // sign-in of its own, and the mailbox would strand the user in the app's
+ // sign-in sheet. handleSubmit offers the hand-off after they sign in
+ // (isAuthenticated stays true, so this effect doesn't run again).
+ if (action === "mailbox") {
let redirectTo = '/';
try {
const saved = sessionStorage.getItem('redirect_after_login');
@@ -390,7 +407,7 @@ function LoginPageContent() {
} catch { /* ignore */ }
router.push(toRouterPath(redirectTo));
}
- }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
+ }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, offerMobileHandoff]);
useEffect(() => {
clearError();
@@ -861,8 +878,11 @@ function LoginPageContent() {
// being remembered: on a shared computer the next person sees the list.
if (!rememberMeEnabled || persistSession) saveUsername(formData.username);
if (isMobileHandoff) {
- // The isAuthenticated effect handles the redirect; nothing else to
- // do here. Don't push to / - that would race the deep link.
+ // Usually the isAuthenticated effect has offered the hand-off by
+ // now. A browser that was already signed in doesn't flip
+ // isAuthenticated, so offer it here (a no-op once taken). Don't push
+ // to / - that would race the deep link.
+ offerMobileHandoff();
return;
}
router.push('/');
@@ -956,8 +976,8 @@ function LoginPageContent() {
if (demoMode && !isAddAccountMode) {
return (
- {/* Theme toggle */}
-
+ {/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
+
- {/* Theme toggle - top right, dropdown style */}
-
+
{
+ setPendingHandoff(null);
+ router.push('/');
+ }}
+ />
+ {/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
+
{
+ 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
new file mode 100644
index 000000000..e33885cc5
--- /dev/null
+++ b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// A version older than the installed one comes back as 409 with a
+// `downgrade` marker (the marketplace route). The admin is asked, like for
+// a withdrawn version, and only then is the install repeated with `force`.
+
+const calls: Array<{ body: Record }> = [];
+let responses: Response[] = [];
+vi.mock('@/lib/browser-navigation', () => ({
+ apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
+ calls.push({ body: JSON.parse(String(init?.body ?? '{}')) });
+ return responses.shift() ?? new Response('{}', { status: 500 });
+ }),
+}));
+vi.mock('next/link', () => ({ default: () => null }));
+
+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 };
+
+beforeEach(() => {
+ calls.length = 0;
+ responses = [];
+});
+
+describe('installVersion', () => {
+ it('asks before installing an older version and repeats with force when the admin agrees', async () => {
+ responses = [json({ error: 'Version 2.0.9 is older than the installed 2.1.0.', downgrade: { installedVersion: '2.1.0' } }, 409), json({ warnings: [] })];
+ const asked: string[] = [];
+ const result = await installVersion(BODY, (message) => { asked.push(message); return true; });
+ expect(asked).toEqual(['Version 2.0.9 is older than the installed 2.1.0.\n\nInstall the older version anyway?']);
+ expect(calls.map((c) => c.body.force)).toEqual([undefined, true]);
+ expect(result.ok).toBe(true);
+ });
+
+ it('does not install an older version the admin declined', async () => {
+ responses = [json({ error: 'older', downgrade: { installedVersion: '2.1.0' } }, 409)];
+ const result = await installVersion(BODY, () => false);
+ expect(result).toEqual({ ok: false, error: 'Not installed.' });
+ expect(calls).toHaveLength(1);
+ });
+
+ it('shows any other 409 as the error it is', async () => {
+ responses = [json({ error: 'The configuration is read-only' }, 409)];
+ const result = await installVersion(BODY, () => true);
+ expect(result).toEqual({ ok: false, error: 'The configuration is read-only' });
+ expect(calls).toHaveLength(1);
+ });
+});
diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
new file mode 100644
index 000000000..34fa74eb8
--- /dev/null
+++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
@@ -0,0 +1,94 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { UnsavedChangesGuard } from '../_unsaved-changes-guard';
+import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
+
+function DirtySection({ id = 'settings', dirty }: { id?: string; dirty: boolean }) {
+ useAdminUnsavedChanges(id, dirty);
+ return null;
+}
+
+afterEach(() => {
+ useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
+});
+
+describe('admin unsaved changes guard (#1255)', () => {
+ it('runs the action straight away when nothing is unsaved', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ });
+
+ it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ expect(screen.getByRole('alertdialog')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Stay' }));
+ expect(action).not.toHaveBeenCalled();
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true });
+ });
+
+ it('Leave runs the action and drops the unsaved flag', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(useAdminUnsavedStore.getState().dirty).toEqual({});
+ });
+
+ it('Escape closes the prompt like Stay', () => {
+ render(<> >);
+ const action = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(action));
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('alertdialog')).toBeNull();
+ expect(action).not.toHaveBeenCalled();
+ });
+
+ it('a scoped leave only looks at, and only clears, its own section', () => {
+ render(<> >);
+ const back = vi.fn();
+ act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
+ expect(back).toHaveBeenCalledTimes(1);
+
+ act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true));
+ act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
+ fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ expect(back).toHaveBeenCalledTimes(2);
+ expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true });
+ });
+
+ it('holds a link click until the admin picks Leave', () => {
+ const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
+ render(
+ <>
+
+
+ Change Password
+ >,
+ );
+ fireEvent.click(screen.getByText('Change Password'));
+ expect(onLinkClick).not.toHaveBeenCalled();
+ expect(screen.getByRole('alertdialog')).toBeTruthy();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ expect(onLinkClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('asks the browser to confirm closing the page only while edits are unsaved', () => {
+ const { rerender } = render(<> >);
+ const dirtyEvent = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(dirtyEvent);
+ expect(dirtyEvent.defaultPrevented).toBe(true);
+
+ rerender(<> >);
+ const cleanEvent = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(cleanEvent);
+ expect(cleanEvent.defaultPrevented).toBe(false);
+ });
+});
diff --git a/app/(main)/admin/_layout-client.tsx b/app/(main)/admin/_layout-client.tsx
new file mode 100644
index 000000000..1a91ca9ac
--- /dev/null
+++ b/app/(main)/admin/_layout-client.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
+import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
+import { AdminShell } from '@/components/admin/admin-shell';
+import { UnsavedChangesGuard } from './_unsaved-changes-guard';
+
+/**
+ * Gates the admin panel behind the admin session and draws its shell. The
+ * pages themselves are one route (./[[...segments]]) and switch through
+ * history.pushState, so this layout stays mounted across them.
+ */
+export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
+ const [authenticated, setAuthenticated] = useState(null);
+ const [authError, setAuthError] = useState(null);
+ const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
+ const [configReadOnly, setConfigReadOnly] = useState(false);
+
+ useEffect(() => {
+ if (pathname === '/admin/login') return;
+ // One check per visit, not one per page: the pages switch without a
+ // route change, and the session does not change with them.
+ if (authenticated === true) return;
+ let cancelled = false;
+
+ async function checkAuth() {
+ try {
+ const jmapHeaders = getActiveAccountSlotHeaders();
+ const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
+ const data = await res.json();
+ if (cancelled) return;
+
+ // `stalwartAutoLogin` is only true in "auto" mode; in "password" mode
+ // a Stalwart admin still lands on the password form (#870).
+ const stalwartAutoLogin = data.stalwartAutoLogin === true;
+ setIsStalwartAutoLogin(stalwartAutoLogin);
+
+ // If neither password-based admin nor Stalwart auto-login, redirect away
+ if (!data.enabled && !stalwartAutoLogin) {
+ router.replace('/');
+ return;
+ }
+
+ if (data.authenticated) {
+ setAuthenticated(true);
+ return;
+ }
+
+ // If Stalwart admin (auto mode) but not yet authenticated, auto-login
+ if (stalwartAutoLogin) {
+ const loginRes = await apiFetch('/api/admin/auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...jmapHeaders },
+ body: JSON.stringify({ stalwartAuth: true }),
+ });
+ if (cancelled) return;
+ if (loginRes.ok) {
+ setAuthenticated(true);
+ return;
+ }
+ const body = await loginRes.json().catch(() => ({}));
+ setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
+ setAuthenticated(false);
+ return;
+ }
+
+ // Carry the page that was asked for through the login, so a connector
+ // link to a panel (/connector/admin_extension?slug=x) does not dump
+ // the admin on the overview after signing in.
+ const next = encodeURIComponent(`${pathname}${window.location.search}`);
+ router.replace(`/admin/login?next=${next}`);
+ } catch (err) {
+ if (cancelled) return;
+ setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
+ setAuthenticated(false);
+ }
+ }
+
+ checkAuth();
+ return () => { cancelled = true; };
+ }, [pathname, router, authenticated]);
+
+ // With ADMIN_CONFIG_READONLY the panel still shows every setting, but
+ // nothing can be saved; say so up front instead of after a failed save (#1249).
+ useEffect(() => {
+ if (!authenticated) return;
+ let cancelled = false;
+ apiFetch('/api/setup/status')
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [authenticated]);
+
+ function handleLogout() {
+ requestLeave(async () => {
+ await apiFetch('/api/admin/auth', { method: 'DELETE' });
+ router.replace('/admin/login');
+ });
+ }
+
+ // Don't gate the login page
+ if (pathname === '/admin/login') {
+ return <>{children}>;
+ }
+
+ return (
+ <>
+
+
+ {authError ? (
+
+
Admin authentication failed
+
{authError}
+
+ ) : authenticated === null ? (
+
+ Loading admin panel…
+
+ ) : authenticated ? children : null}
+
+ >
+ );
+}
diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_pages/_jmap-servers-section.tsx
similarity index 64%
rename from app/(main)/admin/_tabs/_jmap-servers-section.tsx
rename to app/(main)/admin/_pages/_jmap-servers-section.tsx
index 2f0c531e0..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[];
@@ -19,11 +22,15 @@ interface RowDraft {
oauthClientId: string;
oauthIssuerUrl: string;
oauthClientSecret: string;
+ /** The server holds a secret the dashboard is not shown; an empty field keeps it. */
+ hasClientSecret: boolean;
oauthButtonLabel: string;
+ oauthEndSessionUrl: string;
oauthExpanded: boolean;
}
function entryToDraft(e: JmapServerEntry): RowDraft {
+ const hasClientSecret = !!(e.oauth?.hasClientSecret || e.oauth?.clientSecret);
return {
id: e.id,
label: e.label,
@@ -31,9 +38,11 @@ function entryToDraft(e: JmapServerEntry): RowDraft {
domains: (e.domains ?? []).join(', '),
oauthClientId: e.oauth?.clientId ?? '',
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
- oauthClientSecret: e.oauth?.clientSecret ?? '',
+ oauthClientSecret: '',
+ hasClientSecret,
oauthButtonLabel: e.oauth?.buttonLabel ?? '',
- oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
+ oauthEndSessionUrl: e.oauth?.endSessionUrl ?? '',
+ oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || hasClientSecret || e.oauth.endSessionUrl)),
};
}
@@ -49,12 +58,17 @@ function draftToEntry(d: RowDraft): JmapServerEntry | null {
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const buttonLabel = d.oauthButtonLabel.trim();
- const oauth = clientId || issuerUrl || clientSecret
+ const endSessionUrl = d.oauthEndSessionUrl.trim();
+ // A typed secret replaces the stored one; an empty field keeps it (the
+ // server fills it back in), and the flag keeps the block alive so the
+ // server knows there is still an OAuth client here.
+ const oauth = clientId || issuerUrl || clientSecret || endSessionUrl || d.hasClientSecret
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
- ...(clientSecret ? { clientSecret } : {}),
+ ...(clientSecret ? { clientSecret } : d.hasClientSecret ? { hasClientSecret: true } : {}),
...(buttonLabel ? { buttonLabel } : {}),
+ ...(endSessionUrl ? { endSessionUrl } : {}),
}
: undefined;
return {
@@ -75,11 +89,16 @@ function emptyDraft(): RowDraft {
oauthClientId: '',
oauthIssuerUrl: '',
oauthClientSecret: '',
+ hasClientSecret: false,
oauthButtonLabel: '',
+ oauthEndSessionUrl: '',
oauthExpanded: false,
};
}
+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);
@@ -119,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' && (
-
-
-
- )}
-
+
+
+ {source === 'admin' &&
}
+
Add server
-
+
@@ -164,40 +166,42 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
-
ID
+
ID
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
}
- Label
+ Label
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}
/>
-
JMAP URL
+
JMAP URL
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}
/>
remove(i)}
- className="shrink-0 text-muted-foreground hover:text-destructive"
+ className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"
title="Remove server"
+ aria-label="Remove server"
>
@@ -205,7 +209,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
-
+
Email domains (comma-separated, used for auto-pick)
update(i, { domains: e.target.value })}
placeholder="example.com, example.org"
- 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}
/>
- OAuth Client ID
+ OAuth Client ID
update(i, { oauthClientId: e.target.value })}
- 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}
/>
- OAuth Issuer URL
+ OAuth Issuer URL
update(i, { oauthIssuerUrl: e.target.value })}
placeholder="https://auth.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}
/>
- OAuth Client Secret
+ OAuth Client Secret
update(i, { oauthClientSecret: e.target.value })}
- 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"
+ placeholder={d.hasClientSecret ? '•••••••• (saved - type to replace)' : undefined}
+ className={fieldClass}
/>
-
Sign-in button label
+
Sign-in button label
update(i, { oauthButtonLabel: e.target.value })}
placeholder="Sign in with Google"
maxLength={64}
- 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}
/>
With a client ID, this label gives the server its own sign-in button on the login page, whichever server is selected and even while OAuth is off globally.
+
+
Provider logout URL
+
update(i, { oauthEndSessionUrl: e.target.value })}
+ placeholder="https://auth.example.com/logout"
+ className={fieldClass}
+ />
+
+ Where signing out sends the browser to end the provider's session, instead of the end_session_endpoint from discovery. Set it for providers that advertise none, such as Stalwart. Must be https.
+
+
)}
diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx
new file mode 100644
index 000000000..219a801c3
--- /dev/null
+++ b/app/(main)/admin/_pages/auth.tsx
@@ -0,0 +1,324 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Loader2, Sparkles } from '@/components/icons';
+import { apiFetch } from '@/lib/browser-navigation';
+import { adminPageDef } from '@/lib/admin/admin-nav';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
+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 { Flash, useFlash } from '@/components/admin/flash-message';
+import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, inputClass } from '@/components/admin/config-items';
+import { cn } from '@/lib/utils';
+
+interface ConfigEntry {
+ // Sensitive keys (sessionSecret, oauthClientSecret) come back with
+ // `value` omitted and `hasValue` set instead - the server never echoes
+ // the raw secret to the client.
+ value?: unknown;
+ source: 'admin' | 'env' | 'default';
+ hasValue?: boolean;
+}
+
+const PAGE = adminPageDef('auth');
+
+export function AuthPage() {
+ const [config, setConfig] = useState
>({});
+ const [edits, setEdits] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useFlash();
+ const [configReadOnly, setConfigReadOnly] = useState(false);
+
+ useEffect(() => {
+ fetchConfig();
+ apiFetch('/api/setup/status')
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => setConfigReadOnly(data?.readOnly === true))
+ .catch(() => {});
+ }, []);
+
+ async function fetchConfig() {
+ setLoading(true);
+ const res = await apiFetch('/api/admin/config');
+ if (res.ok) setConfig(await res.json());
+ setLoading(false);
+ }
+
+ function handleChange(key: string, value: unknown) {
+ setEdits(prev => ({ ...prev, [key]: value }));
+ setMessage(null);
+ }
+
+ function currentValue(key: string): unknown {
+ if (key in edits) return edits[key];
+ return config[key]?.value;
+ }
+
+ async function handleSave() {
+ if (Object.keys(edits).length === 0) return;
+ setSaving(true);
+ setMessage(null);
+
+ const res = await apiFetch('/api/admin/config', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(edits),
+ });
+
+ if (res.ok) {
+ setMessage({ type: 'success', text: 'Authentication settings saved.' });
+ setEdits({});
+ await fetchConfig();
+ } else {
+ const data = await res.json();
+ setMessage({ type: 'error', text: data.error || 'Failed to save' });
+ }
+ setSaving(false);
+ }
+
+ async function handleRevert(key: string) {
+ const res = await apiFetch('/api/admin/config', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key }),
+ });
+ if (res.ok) {
+ setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
+ await fetchConfig();
+ }
+ }
+
+ const [setupRunning, setSetupRunning] = useState(false);
+ const [setupOpen, setSetupOpen] = useState(false);
+ const [setupOrigin, setSetupOrigin] = useState('');
+ const [setupIssuer, setSetupIssuer] = useState('');
+ const [setupOauthOnly, setSetupOauthOnly] = useState(false);
+
+ function openSetupDialog() {
+ if (typeof window === 'undefined') return;
+ const origin = window.location.origin;
+ const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
+ setSetupOrigin(origin);
+ setSetupIssuer(jmapUrl || origin);
+ setSetupOauthOnly(currentValue('oauthOnly') === true);
+ setSetupOpen(true);
+ }
+
+ async function handleAutoSetup() {
+ setSetupRunning(true);
+ setMessage(null);
+ try {
+ const res = await apiFetch('/api/admin/oauth/setup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ origin: setupOrigin.trim().replace(/\/+$/, ''),
+ issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
+ oauthOnly: setupOauthOnly,
+ }),
+ });
+ const data = await res.json();
+ if (res.ok) {
+ setMessage({
+ type: 'success',
+ text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
+ });
+ setEdits({});
+ setSetupOpen(false);
+ await fetchConfig();
+ } else {
+ const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
+ setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
+ }
+ } catch (err) {
+ setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
+ } finally {
+ setSetupRunning(false);
+ }
+ }
+
+ const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
+ const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
+
+ const hasEdits = Object.keys(edits).length > 0;
+
+ useAdminUnsavedChanges('auth', hasEdits);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
setMessage(null)} />
+
+ {setupOpen && (
+ { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
+ >
+
+
+
Auto-configure OAuth
+
+ Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
+
+
+
+
+
+ Webmail origin
+
+
setSetupOrigin(e.target.value)}
+ disabled={setupRunning}
+ placeholder="https://webmail.example.com"
+ className={cn(inputClass, 'w-full')}
+ />
+
+ Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart.
+
+ {!setupOriginValid && setupOrigin.length > 0 && (
+
Must be like https://host with no path.
+ )}
+
+
+
+ Stalwart issuer URL
+
+
setSetupIssuer(e.target.value)}
+ disabled={setupRunning}
+ placeholder="https://mail.example.com"
+ className={cn(inputClass, 'w-full')}
+ />
+
+ Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL.
+
+ {!setupIssuerValid && setupIssuer.length > 0 && (
+
Must be like https://host with no path.
+ )}
+
+
+ setSetupOauthOnly(e.target.checked)}
+ className="h-3.5 w-3.5 rounded border-input"
+ disabled={setupRunning}
+ />
+ Also enable “OAuth only” (hide password login)
+
+
+
+ setSetupOpen(false)} disabled={setupRunning}>
+ Cancel
+
+
+ {setupRunning ? : }
+ {setupRunning ? 'Configuring…' : 'Configure'}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {setupRunning ? : }
+ {setupRunning ? 'Configuring…' : 'Set up automagically'}
+
+ {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 (
+
+ );
+ }
+
+ /** 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)} />
+
+
+
+
+
+
+
handleScopeChange(null)} aria-pressed={selectedHost === null} className={chipClass(selectedHost === null)}>
+ Default
+
+ {domainEntries.map(entry => (
+
handleScopeChange(entry.host)}
+ aria-pressed={selectedHost === entry.host}
+ className={chipClass(selectedHost === entry.host)}
+ >
+ {entry.host}
+
+ ))}
+ {!addingHost && (
+
{ setAddingHost(true); setNewHostError(null); }}
+ className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
+ >
+
+ Add domain
+
+ )}
+
+ {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')}
+ />
+
+ Add
+
+ { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}>
+ Cancel
+
+ {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.'}
+
+
+
+ Remove domain
+
+
+ ) : (
+
+ 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 = '';
+ }}
+ />
+ fileInputRef.current?.click()}
+ disabled={uploading || uploadDisabled}
+ title={uploadDisabled ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
+ aria-label={uploadDisabled ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
+ >
+ {uploading ? : }
+
+ {uploaded && (
+
+
+
+ )}
+ {overridden && !uploaded && }
+
+ {value && (
+
+
+
+
{ (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) => (
+
+ ))}
+
+ )}
+
+
+
+ 0 ? `${pendingApprovals} awaiting approval` : undefined}
+ subTone="warning"
+ />
+
+
+
+
+
+
+ {config?.appName || '-'}
+
+
+ {jmapHostname}
+
+
+
+ {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {recentActivity.length === 0 ? (
+ No activity recorded yet
+ ) : (
+
+ {recentActivity.map((entry, i) => (
+ {entry.action}}
+ description={formatDetail(entry.detail) || undefined}
+ meta={<>{entry.ip} · {new Date(entry.ts).toLocaleString()}>}
+ />
+ ))}
+
+ )}
+
+ All entries
+
+
+
+
+ );
+}
+
+type StatTone = 'muted' | 'warning' | 'danger';
+
+interface StatTileProps {
+ label: string;
+ value: ReactNode;
+ sub?: ReactNode;
+ subTone?: StatTone;
+ /** Makes the tile a link to that admin page. */
+ href?: string;
+}
+
+const SUB_TONE: Record = {
+ muted: 'text-muted-foreground',
+ warning: 'text-amber-600 dark:text-amber-400',
+ danger: 'text-destructive',
+};
+
+function StatTile({ label, value, sub, subTone = 'muted', href }: StatTileProps) {
+ const body = (
+ <>
+ {label}
+ {value}
+ {sub && {sub}
}
+ >
+ );
+ const className = 'block min-w-0 rounded-lg border border-border p-4';
+ if (href) {
+ return (
+
+ {body}
+
+ );
+ }
+ return {body}
;
+}
+
+function OnOff({ on }: { on: boolean }) {
+ return {on ? 'On' : 'Off'} ;
+}
+
+function formatDetail(detail: Record): string {
+ if (!detail || Object.keys(detail).length === 0) return '';
+ if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
+ if (detail.reason) return String(detail.reason);
+ if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
+ return JSON.stringify(detail).slice(0, 80);
+}
diff --git a/app/(main)/admin/_pages/extension-detail.tsx b/app/(main)/admin/_pages/extension-detail.tsx
new file mode 100644
index 000000000..51d9d8e2b
--- /dev/null
+++ b/app/(main)/admin/_pages/extension-detail.tsx
@@ -0,0 +1,494 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Puzzle, SwatchBook, Loader2, Eye, EyeOff, Lock, RotateCcw, Trash2, Shield, ShieldAlert, Store, Upload } from '@/components/icons';
+import { apiFetch } from '@/lib/browser-navigation';
+import { adminPath } from '@/lib/admin/admin-nav';
+import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { navigateAdmin } from '@/hooks/use-admin-navigation';
+import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
+import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
+import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { AdminLink } from '@/components/admin/admin-link';
+import { AdminSaveBar } from '@/components/admin/save-bar';
+import { Badge } from '@/components/admin/badge';
+import { Flash, useFlash } from '@/components/admin/flash-message';
+import { inputClass } from '@/components/admin/config-items';
+import { ExtensionIcon } from '@/components/admin/extension-icon';
+import { cn } from '@/lib/utils';
+import { useExtensionIcons } from './use-extension-icons';
+
+export interface ConfigField {
+ type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
+ label: string;
+ description?: string;
+ required?: boolean;
+ default?: unknown;
+ placeholder?: string;
+ options?: { label: string; value: string }[];
+}
+
+/** A plugin or theme as /api/admin/plugins and /api/admin/themes list it. */
+export interface ExtensionEntry {
+ id: string;
+ name: string;
+ version: string;
+ author: string;
+ description: string;
+ /** Plugins: hook, ui-extension, ... */
+ type?: string;
+ /** Themes: the colour modes it ships. */
+ variants?: string[];
+ enabled: boolean;
+ forceEnabled?: boolean;
+ permissions?: string[];
+ installedAt: string;
+ updatedAt: string;
+ source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' };
+ /** Versions kept on this server for a rollback, most recently replaced first. */
+ previousVersions?: string[];
+ /** Loaded from the dev plugins directory rather than installed. */
+ dev?: boolean;
+ configSchema?: Record;
+}
+
+type Kind = 'plugin' | 'theme';
+
+const KIND_LABEL: Record = {
+ plugin: { one: 'plugin', many: 'Plugins', endpoint: '/api/admin/plugins' },
+ theme: { one: 'theme', many: 'Themes', endpoint: '/api/admin/themes' },
+};
+
+interface Props {
+ kind: Kind;
+ id: string;
+}
+
+/**
+ * One installed plugin or theme: its settings first, then whether users
+ * can turn it off, what it may do, and the versions kept for a rollback.
+ */
+export function ExtensionDetailPage({ kind, id }: Props) {
+ const words = KIND_LABEL[kind];
+ const listHref = adminPath(kind === 'plugin' ? 'plugins' : 'themes');
+ const [entry, setEntry] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [config, setConfig] = useState>({});
+ const [formValues, setFormValues] = useState>({});
+ const [revealSecrets, setRevealSecrets] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [busy, setBusy] = useState(null);
+ const [message, setMessage] = useFlash();
+ const iconFor = useExtensionIcons();
+
+ const load = useCallback(async () => {
+ const [listRes, configRes] = await Promise.all([
+ apiFetch(words.endpoint),
+ kind === 'plugin' ? apiFetch(`/api/admin/plugins/${encodeURIComponent(id)}/config`) : Promise.resolve(null),
+ ]);
+ if (listRes.ok) {
+ const entries: ExtensionEntry[] = await listRes.json();
+ setEntry(entries.find((e) => e.id === id) ?? null);
+ }
+ if (configRes?.ok) setConfig(await configRes.json());
+ }, [id, kind, words.endpoint]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ load().finally(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [load]);
+
+ const schema = entry?.configSchema;
+ const hasSchema = !!schema && Object.keys(schema).length > 0;
+
+ const savedValues = useMemo(() => {
+ const initial: Record = {};
+ if (!schema) return initial;
+ for (const [key, field] of Object.entries(schema)) {
+ const stored = config[key];
+ if (stored !== undefined && stored !== null) initial[key] = String(stored);
+ else if (field.default !== undefined) initial[key] = String(field.default);
+ else initial[key] = '';
+ }
+ return initial;
+ }, [schema, config]);
+
+ useEffect(() => {
+ if (!schema) return;
+ setFormValues(savedValues);
+ }, [schema, savedValues]);
+
+ const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]);
+ useAdminUnsavedChanges(`${kind}-config`, dirty);
+
+ async function saveConfig() {
+ if (!schema) return;
+ for (const [key, field] of Object.entries(schema)) {
+ if (field.required && !formValues[key]?.trim()) {
+ setMessage({ type: 'error', text: `"${field.label}" is required` });
+ return;
+ }
+ }
+ setSaving(true);
+ setMessage(null);
+ try {
+ let firstError: string | null = null;
+ let failed = false;
+ const noteError = async (res: Response) => {
+ failed = true;
+ if (firstError) return;
+ const data = await res.json().catch(() => null);
+ if (typeof data?.error === 'string') firstError = data.error;
+ };
+ const endpoint = `/api/admin/plugins/${encodeURIComponent(id)}/config`;
+ for (const [key, field] of Object.entries(schema)) {
+ const newVal = formValues[key] ?? '';
+ const oldVal = config[key] !== undefined ? String(config[key]) : '';
+ if (newVal === oldVal) continue;
+ if (field.type === 'secret' && !newVal && config[key]) continue;
+
+ if (!newVal && !field.required) {
+ const res = await apiFetch(endpoint, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key }),
+ });
+ if (res.ok) setConfig((prev) => { const next = { ...prev }; delete next[key]; return next; });
+ else await noteError(res);
+ continue;
+ }
+
+ let value: unknown = newVal;
+ if (field.type === 'boolean') value = newVal === 'true';
+ else if (field.type === 'number') value = Number(newVal);
+ const res = await apiFetch(endpoint, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key, value }),
+ });
+ if (res.ok) setConfig((prev) => ({ ...prev, [key]: value }));
+ else await noteError(res);
+ }
+ setMessage(failed
+ ? { type: 'error', text: firstError ?? 'Some settings could not be saved' }
+ : { type: 'success', text: 'Settings saved' });
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the settings' });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function patch(body: Record, what: string): Promise {
+ setBusy(what);
+ setMessage(null);
+ try {
+ const res = await apiFetch(words.endpoint, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id, ...body }),
+ });
+ if (res.ok) return true;
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || `Could not change the ${words.one}` });
+ return false;
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function setEnabled(enabled: boolean) {
+ if (await patch({ enabled }, 'enabled')) setEntry((prev) => (prev ? { ...prev, enabled } : prev));
+ }
+
+ async function setForced(forceEnabled: boolean) {
+ const body: Record = { forceEnabled };
+ if (forceEnabled) body.enabled = true;
+ if (await patch(body, 'forced')) {
+ setEntry((prev) => (prev ? { ...prev, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : prev));
+ }
+ }
+
+ async function rollback(version: string) {
+ if (!entry) return;
+ if (!confirm(`Put v${version} of "${entry.name}" back in place of v${entry.version}? The copy kept on this server is used.`)) return;
+ setBusy(`rollback:${version}`);
+ setMessage(null);
+ try {
+ const res = await apiFetch(`/api/admin/extensions/${kind}/${encodeURIComponent(id)}/rollback`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ version }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (res.ok) {
+ setMessage({ type: 'success', text: `"${entry.name}" rolled back to v${version}` });
+ await load();
+ } else {
+ setMessage({ type: 'error', text: data.error || 'Rollback failed' });
+ }
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function remove() {
+ if (!entry) return;
+ if (!confirm(`Remove "${entry.name}" for every user? Its kept versions are removed too; this cannot be undone.`)) return;
+ setBusy('remove');
+ setMessage(null);
+ const res = await apiFetch(words.endpoint, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id }),
+ });
+ if (res.ok) {
+ navigateAdmin(listHref);
+ return;
+ }
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not remove it' });
+ setBusy(null);
+ }
+
+ const back = { href: listHref, label: words.many };
+ const Icon = kind === 'plugin' ? Puzzle : SwatchBook;
+
+ if (loading && !entry) {
+ return (
+
+
Loading} />
+
+ Loading
+
+
+ );
+ }
+
+ if (!entry) {
+ return (
+
+
Nothing is installed under the id {id}.>} />
+
+ );
+ }
+
+ const perms = [...new Set(entry.permissions ?? [])]
+ .sort((a, b) => Number(HIGH_RISK_PERMISSIONS.has(b)) - Number(HIGH_RISK_PERMISSIONS.has(a)));
+ const previous = entry.previousVersions ?? [];
+
+ return (
+
+ )}
+ title={(
+
+ {entry.name}
+ v{entry.version}
+ {entry.forceEnabled && }>Forced on}
+ {entry.dev && Dev }
+
+ )}
+ description={(
+ <>
+ by {entry.author || 'unknown'}
+ {entry.type && <> · {entry.type}>}
+ {entry.variants && entry.variants.length > 0 && <> · {entry.variants.join(', ')}>}
+ {entry.installedAt && <> · installed {new Date(entry.installedAt).toLocaleDateString()}>}
+ >
+ )}
+ actions={(
+ <>
+
+ {entry.enabled ? 'On' : 'Off'}
+ void setEnabled(v)} ariaLabel={`${entry.name} enabled`} />
+
+ void remove()}>
+ {busy === 'remove' ? : }
+ Remove
+
+ >
+ )}
+ />
+
+ {entry.description && {entry.description}
}
+
+ setMessage(null)} />
+
+
+
+
+ void setForced(v)} />
+
+
+ {entry.source?.kind === 'marketplace' ? (
+
+ Open in marketplace
+
+ ) : (
+ Upload
+ )}
+
+
+
+ {kind === 'plugin' && (
+
+ {perms.length > 0 && (
+
+ {perms.map((perm) => {
+ const risky = HIGH_RISK_PERMISSIONS.has(perm);
+ return (
+
+ {risky
+ ?
+ : }
+ {perm}
+ {risky && High risk }
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+
+ v{entry.version}
+
+ {previous.map((version) => (
+
+ void rollback(version)}>
+ {busy === `rollback:${version}` ? : }
+ Roll back
+
+
+ ))}
+
+
+ {kind === 'plugin' &&
}
+
+ {/* The plugin's own settings come last: they are the long part. */}
+ {kind === 'plugin' && hasSchema && schema && (
+
+ {Object.entries(schema).map(([key, field]) => (
+
+ setRevealSecrets((prev) => ({ ...prev, [key]: !prev[key] }))}
+ onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))}
+ />
+
+ ))}
+
+ )}
+
+
+ setFormValues(savedValues)}
+ saveLabel="Save settings"
+ />
+
+ );
+}
+
+function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange }: {
+ field: ConfigField;
+ value: string;
+ stored: boolean;
+ revealed: boolean;
+ onReveal: () => void;
+ onChange: (value: string) => void;
+}) {
+ const width = 'w-full sm:w-64';
+ if (field.type === 'boolean') {
+ return (
+
onChange(v ? 'true' : 'false')} />
+ );
+ }
+ if (field.type === 'select' && field.options) {
+ return (
+ onChange(e.target.value)} aria-label={field.label} className={cn(inputClass, width, 'cursor-pointer')}>
+ Choose
+ {field.options.map((opt) => {opt.label} )}
+
+ );
+ }
+ 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')}
+ />
+
+ {revealed ? : }
+
+
+ );
+ }
+ 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={(
+ <>
+ { setActionFilter(e.target.value); setPage(1); }}
+ aria-label="Filter by action"
+ className={cn(inputClass, 'pe-8 cursor-pointer')}
+ >
+ All actions
+ Login
+ Logout
+ Login Failed
+ Login Blocked
+ Password Change
+ Config Update
+ Config Revert
+ Policy Update
+
+
+
+ Refresh
+
+ >
+ )}
+ />
+
+ {initialLoading ? (
+
+ Loading
+
+ ) : entries.length === 0 ? (
+
+ ) : (
+ <>
+
+ {entries.map((entry, i) => (
+
+
+ {entry.action}
+
+ {new Date(entry.ts).toLocaleString()}
+
+
+
+ {formatDetail(entry.detail)}
+
+
+ {entry.ip}
+
+
+ ))}
+
+
+
+
+
+
+ Time
+ Action
+ Details
+ IP
+
+
+
+ {entries.map((entry, i) => (
+
+
+ {new Date(entry.ts).toLocaleString()}
+
+
+ {entry.action}
+
+
+ {formatDetail(entry.detail)}
+
+
+ {entry.ip}
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+ setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
+ Previous
+
+ setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
+ Next
+
+
+
+ )}
+
+ );
+}
+
+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 && (
+ pick(ext.latest!.version)} title={`Preview v${ext.latest!.version}`} className="rounded hover:underline">
+ }>v{ext.latest!.version} available
+
+ )}
+ {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 && (
+ 0 && !previewIsInstalled && 'bg-blue-600 text-white hover:bg-blue-700')}
+ onClick={() => handleInstall(preview.version)}
+ disabled={!!busy || !canInstallPreview}
+ title={!previewFits
+ ? `v${preview.version} needs Bulwark ${preview.minAppVersion ?? 'any'}${preview.maxAppVersion ? ` to ${preview.maxAppVersion}` : ' or later'}. You are running v${CURRENT_APP_VERSION}.`
+ : preview.yanked ? 'This version was withdrawn by its publisher; you will be asked to confirm.' : undefined}
+ >
+ {busy === `install:${preview.version}` ? : previewDirection > 0 ? : }
+ {installLabel}
+
+ )}
+ {data.installed && (
+ <>
+
+
+ Manage
+
+
+ {uninstalling ? : }
+ Uninstall
+
+ >
+ )}
+
+
+ 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 && (
+ <> pick(ext.latest!.version)}>v{ext.latest.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} )}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Version
+ Released
+ Needs Bulwark
+ Size
+ Status
+ Actions
+
+
+
+ {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 (
+
+
+
+ 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 && (
+ pick(v.version)} title="Preview this version">
+ Preview
+
+ )}
+ {kept && !isInstalled && data.installed && (
+ handleRollback(v.version)} disabled={!!busy}>
+ {busy === `rollback:${v.version}` ? : }
+ Roll back
+
+ )}
+ {!isInstalled && !kept && runs && !suspended && (
+ handleInstall(v.version)}
+ disabled={!!busy}
+ title={v.yanked ? 'Withdrawn; you will be asked to confirm' : undefined}
+ >
+ {busy === key ? : }
+ {v.yanked ? 'Install anyway' : 'Install'}
+
+ )}
+
+
+
+ );
+ })}
+ {extraHistory.map((h) => (
+
+
+ v{h.version}
+ No longer in the directory
+
+ replaced {fmtDate(h.savedAt)}
+ –
+ –
+
+ Kept here
+
+
+
+ {data.installed && (
+ handleRollback(h.version)} disabled={!!busy}>
+ {busy === `rollback:${h.version}` ? : }
+ Roll back
+
+ )}
+
+
+
+ ))}
+ {ext.versions.length === 0 && extraHistory.length === 0 && (
+ No versions published.
+ )}
+
+
+
+
+
+ {ext.screenshots.length > 0 && (
+
+
+ {ext.screenshots.map((s, i) => (
+
+ ))}
+
+
+ )}
+
+ {!isPlugin && ext.themePreviews.length > 0 && (
+
+
+ {ext.themePreviews.map(preview => (
+ preview.url
+ ? (
+
+
+ {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 && (
+
+
+
setShowManifest(v => !v)}
+ aria-expanded={showManifest}
+ className="flex w-full items-center justify-between gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/30"
+ >
+
+
+ manifest.json
+
+ {showManifest ? : }
+
+ {showManifest && (
+
+ {JSON.stringify(bundle.manifest, null, 2)}
+
+ )}
+
+
+ )}
+
+ {bundle.source && (
+
+
+
setShowSource(v => !v)}
+ aria-expanded={showSource}
+ className="flex w-full items-center justify-between gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/30"
+ >
+
+
+ {bundle.source.name}
+ {bundle.source.truncated && truncated }
+
+ {showSource ? : }
+
+ {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/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx
new file mode 100644
index 000000000..a58925975
--- /dev/null
+++ b/app/(main)/admin/_pages/marketplace.tsx
@@ -0,0 +1,751 @@
+'use client';
+
+import { useEffect, useState, useCallback, useMemo } from 'react';
+import {
+ Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle,
+ ArrowUpCircle, ExternalLink, RefreshCw, Ban, ShieldAlert, Info, Upload,
+} from '@/components/icons';
+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';
+
+interface SearchResult {
+ data: MarketplaceListing[];
+ meta: { page: number; perPage: number; total: number };
+ channel: 'stable' | 'beta';
+ directoryUrl: string;
+}
+
+interface InstalledEntry {
+ id: string;
+ kind: 'plugin' | 'theme';
+ name: string;
+ version: string;
+ enabled: boolean;
+ source: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' } | null;
+}
+
+interface UpdatesResult {
+ installed: InstalledEntry[];
+ updates: Array<{ slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; changelog: string | null }>;
+ yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>;
+ deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>;
+ suspended: string[];
+ advisories: Array<{ id: string; extension: string; severity: string; summary: string; fixedIn: string | null; installedVersion?: string }>;
+ unknown: string[];
+ directoryUrl: string;
+ channel: 'stable' | 'beta';
+ error: string | null;
+}
+
+type TypeFilter = 'all' | 'plugin' | 'theme';
+type Sort = 'created' | 'downloads' | 'updated' | 'name';
+type View = 'browse' | 'installed';
+
+const SORTS: Array<{ value: Sort; label: string }> = [
+ { value: 'created', label: 'Newest' },
+ { value: 'downloads', label: 'Most downloaded' },
+ { value: 'updated', label: 'Recently updated' },
+ { value: 'name', label: 'Name' },
+];
+
+const PAGE = adminPageDef('marketplace');
+
+export const detailHref = (slug: string, version?: string | null) =>
+ `${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,
+ * or one older than the installed version, comes back as 409; the admin is
+ * asked, and the install is repeated with `force` if they insist. Returns
+ * the error text to show, or null.
+ */
+export async function installVersion(
+ body: { slug: string; version: string; type: 'plugin' | 'theme' },
+ confirm: (message: string) => boolean = (m) => window.confirm(m),
+): Promise<{ ok: true; warnings: string[]; signature: string | null; updated: boolean } | { ok: false; error: string }> {
+ const send = (force: boolean) => apiFetch('/api/admin/marketplace', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(force ? { ...body, force: true } : body),
+ });
+ try {
+ let res = await send(false);
+ if (res.status === 409) {
+ const data = await res.json().catch(() => ({}));
+ if (data.yanked) {
+ if (!confirm(`${data.error}\n\nInstall it anyway?`)) return { ok: false, error: 'Not installed.' };
+ res = await send(true);
+ } else if (data.downgrade) {
+ if (!confirm(`${data.error}\n\nInstall the older version anyway?`)) return { ok: false, error: 'Not installed.' };
+ res = await send(true);
+ } else {
+ return { ok: false, error: data.error || 'Installation failed' };
+ }
+ }
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return { ok: false, error: data.error || 'Installation failed' };
+ return { ok: true, warnings: data.warnings ?? [], signature: data.signature ?? null, updated: !!data.updated };
+ } catch {
+ return { ok: false, error: 'Installation failed - network error' };
+ }
+}
+
+export function MarketplacePage() {
+ const [extensions, setExtensions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [query, setQuery] = useState('');
+ const [typeFilter, setTypeFilter] = useState('all');
+ const [sort, setSort] = useState('created');
+ const [view, setView] = useState('browse');
+ const [page, setPage] = useState(1);
+ const [total, setTotal] = useState(0);
+ const [perPage] = useState(12);
+ const [directoryUrl, setDirectoryUrl] = useState(null);
+ const [channel, setChannel] = useState<'stable' | 'beta'>('stable');
+ const [installing, setInstalling] = useState>(new Set());
+ const [message, setMessage] = useFlash();
+ const [error, setError] = useState(null);
+ const [updates, setUpdates] = useState(null);
+ const [updatesLoading, setUpdatesLoading] = useState(true);
+
+ const fetchExtensions = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams();
+ if (query) params.set('q', query);
+ if (typeFilter !== 'all') params.set('type', typeFilter);
+ params.set('page', String(page));
+ params.set('perPage', String(perPage));
+ params.set('sort', sort);
+ params.set('order', sort === 'name' ? 'asc' : 'desc');
+
+ const res = await apiFetch(`/api/admin/marketplace?${params}`);
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setError(data.error || 'Failed to connect to extension directory');
+ setExtensions([]);
+ return;
+ }
+
+ const data: SearchResult = await res.json();
+ setExtensions(data.data || []);
+ setTotal(data.meta?.total || 0);
+ setDirectoryUrl(data.directoryUrl ?? null);
+ setChannel(data.channel ?? 'stable');
+ } catch {
+ setError('Failed to connect to extension directory. Make sure it is running.');
+ setExtensions([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [query, typeFilter, page, perPage, sort]);
+
+ const fetchUpdates = useCallback(async () => {
+ setUpdatesLoading(true);
+ try {
+ const res = await apiFetch('/api/admin/marketplace/updates');
+ if (res.ok) setUpdates(await res.json());
+ } catch { /* the strip just stays empty */ } finally {
+ setUpdatesLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { fetchExtensions(); }, [fetchExtensions]);
+ useEffect(() => { fetchUpdates(); }, [fetchUpdates]);
+
+ const [searchInput, setSearchInput] = useState('');
+ useEffect(() => {
+ const t = setTimeout(() => {
+ setQuery(searchInput);
+ setPage(1);
+ }, 300);
+ return () => clearTimeout(t);
+ }, [searchInput]);
+
+ const markInstalling = (slug: string, on: boolean) => setInstalling((prev) => {
+ const next = new Set(prev);
+ if (on) next.add(slug); else next.delete(slug);
+ return next;
+ });
+
+ async function install(ext: { slug: string; name: string; type: 'plugin' | 'theme' }, version: string, isUpdate: boolean) {
+ markInstalling(ext.slug, true);
+ setMessage(null);
+ const result = await installVersion({ slug: ext.slug, version, type: ext.type });
+ markInstalling(ext.slug, false);
+ if (!result.ok) {
+ setMessage({ type: 'error', text: result.error });
+ return false;
+ }
+ 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
+ ? `"${ext.name}" updated to v${version}${signed}${warnings}`
+ : `"${ext.name}" installed (v${version})${signed}${warnings}`,
+ });
+ setExtensions((prev) => prev.map((e) => (e.slug === ext.slug
+ ? { ...e, installed: true, installedVersion: version, installedSource: { kind: 'marketplace', slug: ext.slug, directoryUrl: directoryUrl ?? '' } }
+ : e)));
+ fetchUpdates();
+ return true;
+ }
+
+ async function updateAll() {
+ if (!updates) return;
+ const byId = new Map(updates.installed.map((e) => [e.id, e]));
+ let failed = 0;
+ for (const u of updates.updates) {
+ const entry = byId.get(u.slug);
+ if (!entry) continue;
+ const ok = await install({ slug: u.slug, name: entry.name, type: entry.kind }, u.version, true);
+ if (!ok) failed++;
+ }
+ setMessage(failed
+ ? { type: 'error', text: `${failed} update${failed === 1 ? '' : 's'} failed; see above` }
+ : { type: 'success', text: `All ${updates.updates.length} update${updates.updates.length === 1 ? '' : 's'} installed` });
+ }
+
+ const totalPages = Math.ceil(total / perPage);
+ const installedCount = updates?.installed.length ?? 0;
+ const noticeCount = updates
+ ? updates.updates.length + updates.yanked.length + updates.advisories.length + updates.deprecated.length + updates.suspended.length
+ : 0;
+
+ return (
+
+
+ {channel === 'beta' && (
+ Beta channel
+ )}
+ {directoryUrl && (
+
+
+ Open directory
+
+ )}
+ >
+ )}
+ />
+
+ setMessage(null)} />
+
+
+ {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]) => (
+ setView(v)} aria-pressed={view === v} className={pillClass(view === v)}>
+ {label}
+
+ ))}
+
+
+
+ setSearchInput(e.target.value)}
+ aria-label={view === 'browse' ? 'Search extensions' : 'Filter installed'}
+ className={cn(inputClass, 'w-full ps-9')}
+ />
+
+ {view === 'browse' && (
+ <>
+
+ {(['all', 'plugin', 'theme'] as const).map((t) => (
+ { setTypeFilter(t); setPage(1); }}
+ aria-pressed={typeFilter === t}
+ className={pillClass(typeFilter === t)}
+ >
+ {t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
+
+ ))}
+
+
{ setSort(e.target.value as Sort); setPage(1); }}
+ aria-label="Sort"
+ className={cn(inputClass, 'cursor-pointer self-start pe-8 sm:self-auto')}
+ >
+ {SORTS.map((s) => {s.label} )}
+
+ >
+ )}
+
+
+ {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 && (
+
+ setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
+ Previous
+
+
+ Page {page} of {totalPages}
+
+ setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
+ Next
+
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+
+ );
+}
+
+/** Updates and warnings for what is installed, from one directory request. */
+function UpdatesSection({
+ updates,
+ installing,
+ onUpdate,
+ onUpdateAll,
+}: {
+ updates: UpdatesResult;
+ installing: Set;
+ onUpdate: (u: UpdatesResult['updates'][number]) => void;
+ onUpdateAll: () => void;
+}) {
+ 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 > 1 && (
+
+
+ {busy ? : }
+ Update all
+
+
+ )}
+
+ {updates.updates.map((u) => (
+
+
+
+
{name(u.slug)}
+
v{u.currentVersion} → v{u.version}
+ {u.channel === 'beta' &&
beta }
+
+ {u.changelog &&
{u.changelog}
}
+
+ onUpdate(u)}
+ disabled={installing.has(u.slug)}
+ >
+ {installing.has(u.slug) ? : }
+ Update
+
+
+ ))}
+
+
+ )}
+
+ {hasNotices && (
+
+ {updates.advisories.map((a) => (
+
+
+
+ {name(a.extension)}
+ {' '}has a security advisory ({a.severity}): {a.summary}
+ {a.fixedIn ? ` Fixed in v${a.fixedIn}.` : ' No fixed version yet.'}
+
+
+ ))}
+ {updates.suspended.map((slug) => (
+
+
+
+ {name(slug)}
+ {' '}was suspended by the directory. Consider removing it.
+
+
+ ))}
+ {updates.yanked.map((y) => (
+
+
+
+ The installed v{y.version} of{' '}
+ {name(y.slug)}
+ {' '}was withdrawn by its publisher{y.reason ? `: ${y.reason}` : '.'}
+
+
+ ))}
+ {updates.deprecated.map((d) => (
+
+
+
+ {name(d.slug)}
+ {' '}is deprecated{d.message ? `: ${d.message}` : '.'}
+ {d.replacement && <> Use {d.replacement} instead.>}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+/** Everything installed here, with where it came from and whether it can be updated. */
+function InstalledView({
+ updates,
+ loading,
+ filter,
+ installing,
+ onUpdate,
+}: {
+ updates: UpdatesResult | null;
+ loading: boolean;
+ filter: string;
+ installing: Set;
+ onUpdate: (entry: InstalledEntry, version: string) => void;
+}) {
+ const rows = useMemo(() => {
+ if (!updates) return [];
+ const q = filter.trim().toLowerCase();
+ const newer = new Map(updates.updates.map((u) => [u.slug, u.version]));
+ const yanked = new Set(updates.yanked.map((y) => y.slug));
+ const advisories = new Set(updates.advisories.map((a) => a.extension));
+ return updates.installed
+ .filter((e) => !q || e.name.toLowerCase().includes(q) || e.id.toLowerCase().includes(q))
+ .map((e) => ({ entry: e, newer: newer.get(e.id) ?? null, yanked: yanked.has(e.id), advisory: advisories.has(e.id) }));
+ }, [updates, filter]);
+
+ if (loading && !updates) {
+ return (
+
+ Loading
+
+ );
+ }
+
+ if (rows.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {updates?.error && (
+
Could not check for updates: {updates.error}
+ )}
+
+ {rows.map(({ entry, newer, yanked, advisory }) => {
+ const fromMarketplace = entry.source?.kind === 'marketplace' || entry.source === null;
+ const manageHref = entry.kind === 'plugin' ? adminPath('plugins', entry.id) : adminPath('themes', entry.id);
+ return (
+ : }
+ title={entry.name}
+ titleExtra={(
+ <>
+ v{entry.version}
+ {!entry.enabled && Off }
+ {entry.source?.kind === 'upload'
+ ? }>Uploaded
+ : }>Marketplace}
+ {advisory && }>Advisory}
+ {yanked && }>Withdrawn}
+ >
+ )}
+ control={(
+ <>
+ {newer && (
+ onUpdate(entry, newer)}
+ disabled={installing.has(entry.id)}
+ >
+ {installing.has(entry.id) ? : }
+ Update to v{newer}
+
+ )}
+ Manage
+ >
+ )}
+ />
+ );
+ })}
+
+
+ );
+}
+
+function Rating({ rating }: { rating: { average: number; count: number } }) {
+ if (!rating.count) return null;
+ return (
+
+
+ {rating.average.toFixed(1)}
+ ({rating.count})
+
+ );
+}
+
+function ExtensionCard({
+ extension,
+ installing,
+ onInstall,
+}: {
+ extension: MarketplaceListing;
+ installing: boolean;
+ onInstall: () => void;
+}) {
+ const isPlugin = extension.type === 'plugin';
+ const previewHref = detailHref(extension.slug);
+ const offered = extension.latest;
+ const updateAvailable = extension.installed
+ && !!extension.installedVersion
+ && !!offered
+ && compareVersions(offered.version, extension.installedVersion) > 0;
+
+ return (
+
+ {extension.bannerUrl && (
+
+
+
+ )}
+
+
+
+ {extension.iconUrl ? (
+
+ ) : isPlugin ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {extension.name}
+
+ {extension.featured && (
+
+ )}
+
+
+
+ {isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
+
+ {offered?.channel === 'beta' && beta }
+ {extension.deprecated && deprecated }
+ {extension.author && (
+
+ by {extension.author.displayName}
+
+ )}
+
+
+
+
+
+ {extension.description}
+
+
+ {extension.tags && extension.tags.length > 0 && (
+
+ {extension.tags.slice(0, 3).map(tag => (
+ {tag}
+ ))}
+
+ )}
+
+
+
+
+
+ {extension.totalDownloads.toLocaleString()}
+
+
+ {extension.permissions && extension.permissions.length > 0 && (
+
+ {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
+
+ )}
+
+
+
+ Details
+
+
+
+
+
+ {extension.installed && updateAvailable && offered ? (
+
{ e.preventDefault(); e.stopPropagation(); onInstall(); }}
+ disabled={installing}
+ title={`Update from v${extension.installedVersion} to v${offered.version}`}
+ >
+ {installing ? (
+
+ ) : (
+
+ )}
+ Update to v{offered.version}
+
+ ) : extension.installed ? (
+
}
+ title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
+ >
+ Installed{extension.installedVersion ? ` v${extension.installedVersion}` : ''}
+
+ ) : !offered ? (
+
}
+ title={`No published version runs on Bulwark v${CURRENT_APP_VERSION}. Open the details to see what the newest version needs.`}
+ >
+ Not for v{CURRENT_APP_VERSION}
+
+ ) : (
+
{ e.preventDefault(); e.stopPropagation(); onInstall(); }}
+ disabled={installing}
+ title={`Install v${offered.version}`}
+ >
+ {installing ? (
+
+ ) : (
+
+ )}
+ Quick install
+
+ )}
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/plugin-approvals-panel.tsx b/app/(main)/admin/_pages/plugin-approvals-panel.tsx
new file mode 100644
index 000000000..0baedaf67
--- /dev/null
+++ b/app/(main)/admin/_pages/plugin-approvals-panel.tsx
@@ -0,0 +1,209 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { AlertTriangle, Check, Code, Loader2, X } from '@/components/icons';
+import { apiFetch } from '@/lib/browser-navigation';
+import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
+import { SettingsSection } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { useAdminShell } from '@/components/admin/shell-context';
+import { Badge, type BadgeTone } from '@/components/admin/badge';
+
+interface ApprovalEntry {
+ pluginId: string;
+ bundleHash: string;
+ status: 'pending' | 'approved' | 'denied';
+ manifest: {
+ name?: string;
+ version?: string;
+ author?: string;
+ description?: string;
+ permissions?: string[];
+ httpOrigins?: string[];
+ apiPostPaths?: string[];
+ };
+ requestedBy: string;
+ requestedAt: string;
+ decidedAt?: string;
+}
+
+interface SourceView {
+ entry: ApprovalEntry;
+ code?: string;
+ warnings?: string[];
+ error?: string;
+}
+
+const STATUS_TONE: Record = {
+ pending: 'warning',
+ approved: 'success',
+ denied: 'muted',
+};
+
+/**
+ * Plugins users installed themselves and asked to have approved. The admin
+ * decides on the package the user uploaded: its manifest and code are read
+ * on the server, and the approval is pinned to that exact package. Shown
+ * only while there is something to decide or to look back on.
+ */
+export function PluginApprovalsPanel() {
+ const [entries, setEntries] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(null);
+ const [source, setSource] = useState(null);
+ const [showPast, setShowPast] = useState(false);
+ const refreshBadges = useAdminShell()?.refreshBadges;
+
+ const load = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/plugin-approvals');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json() as { entries: ApprovalEntry[] };
+ setEntries([...data.entries].sort((a, b) =>
+ Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt)));
+ setError(null);
+ } catch (e) {
+ setError(`Could not load approval requests (${(e as Error).message})`);
+ }
+ }, []);
+
+ useEffect(() => { void load(); }, [load]);
+
+ async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') {
+ const key = `${entry.pluginId}:${entry.bundleHash}`;
+ setBusy(key);
+ try {
+ const res = decision === 'revoke'
+ ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' })
+ : await apiFetch('/api/admin/plugin-approvals', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ await load();
+ refreshBadges?.();
+ } catch (e) {
+ setError(`Could not save the decision (${(e as Error).message})`);
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function viewSource(entry: ApprovalEntry) {
+ setSource({ entry });
+ try {
+ const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`);
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
+ setSource({ entry, code: data.code, warnings: data.warnings });
+ } catch (e) {
+ setSource({ entry, error: (e as Error).message });
+ }
+ }
+
+ if (!error && (entries === null || entries.length === 0)) return null;
+
+ const pending = entries?.filter((e) => e.status === 'pending') ?? [];
+ const past = entries?.filter((e) => e.status !== 'pending') ?? [];
+
+ const renderEntry = (entry: ApprovalEntry) => {
+ const key = `${entry.pluginId}:${entry.bundleHash}`;
+ const perms = entry.manifest.permissions ?? [];
+ const sendsTo = [...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])];
+ return (
+
+
+ {entry.manifest.name ?? entry.pluginId}
+ v{entry.manifest.version}
+ {entry.status}
+
+ {entry.manifest.description &&
{entry.manifest.description}
}
+
+ {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
+
+ {perms.length > 0 && (
+
+ {perms.map((p) => (
+ {p}
+ ))}
+
+ )}
+ {sendsTo.length > 0 && (
+
Can send data to: {sendsTo.join(', ')}
+ )}
+
+ viewSource(entry)}>
+ View code
+
+ {entry.status !== 'approved' && (
+ decide(entry, 'approved')}>
+ Approve
+
+ )}
+ {entry.status !== 'denied' && (
+ decide(entry, 'denied')}>
+ Deny
+
+ )}
+ {entry.status !== 'pending' && (
+ decide(entry, 'revoke')}>
+ Forget decision
+
+ )}
+
+
+ );
+ };
+
+ return (
+ 0 ? `Approval requests (${pending.length})` : 'Approval requests'}
+ description="Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail."
+ >
+ {error && {error}
}
+ {pending.length > 0 && (
+
+ {pending.map(renderEntry)}
+
+ )}
+ {past.length > 0 && (
+
+
setShowPast((v) => !v)}
+ className="text-xs font-medium text-muted-foreground hover:text-foreground"
+ aria-expanded={showPast}
+ >
+ {showPast ? 'Hide' : 'Show'} {past.length} past decision{past.length === 1 ? '' : 's'}
+
+ {showPast && (
+
+ {past.map(renderEntry)}
+
+ )}
+
+ )}
+
+ {source && (
+ { if (e.target === e.currentTarget) setSource(null); }}>
+
+
+ {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}
+ setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent">
+
+ {source.warnings && source.warnings.length > 0 && (
+
+
{source.warnings.join('; ')}
+
+ )}
+
+ {source.error ?? source.code ?? ( Loading )}
+
+
+
+ )}
+
+ );
+}
diff --git a/app/(main)/admin/_pages/plugins.tsx b/app/(main)/admin/_pages/plugins.tsx
new file mode 100644
index 000000000..536b4276f
--- /dev/null
+++ b/app/(main)/admin/_pages/plugins.tsx
@@ -0,0 +1,286 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Upload, Loader2, Puzzle, Lock, Store, Power, PowerOff, ShieldAlert } from '@/components/icons';
+import type { SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+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 { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { AdminButtonLink } from '@/components/admin/button-link';
+import { AdminList, AdminListRow } from '@/components/admin/list';
+import { AdminSaveBar } from '@/components/admin/save-bar';
+import { Badge } from '@/components/admin/badge';
+import { EmptyState } from '@/components/admin/empty-state';
+import { Flash, useFlash } from '@/components/admin/flash-message';
+import { ExtensionIcon } from '@/components/admin/extension-icon';
+import { PluginApprovalsPanel } from './plugin-approvals-panel';
+import { useExtensionIcons } from './use-extension-icons';
+import type { ExtensionEntry } from './extension-detail';
+
+export function PluginsPage() {
+ const [plugins, setPlugins] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [uploading, setUploading] = useState(false);
+ const [bulkBusy, setBulkBusy] = useState(false);
+ const [message, setMessage] = useFlash();
+ const fileInputRef = useRef(null);
+ const [policy, setPolicy] = useState({ ...DEFAULT_POLICY });
+ const [savedPolicy, setSavedPolicy] = useState({ ...DEFAULT_POLICY });
+ const [savingPolicy, setSavingPolicy] = useState(false);
+ const iconFor = useExtensionIcons();
+
+ const fetchPlugins = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/plugins');
+ if (res.ok) setPlugins(await res.json());
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchPolicy = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/policy');
+ if (res.ok) {
+ const data = await res.json();
+ setPolicy(data);
+ setSavedPolicy(data);
+ }
+ } catch { /* the defaults stay */ }
+ }, []);
+
+ useEffect(() => { fetchPlugins(); fetchPolicy(); }, [fetchPlugins, fetchPolicy]);
+
+ const features = policy.features;
+ const pluginsEnabled = features.pluginsEnabled ?? true;
+ const pluginsUploadEnabled = features.pluginsUploadEnabled ?? true;
+ const requirePluginApproval = features.requirePluginApproval ?? true;
+ const policyDirty = JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
+ useAdminUnsavedChanges('plugins', policyDirty);
+
+ function setFeature(key: 'pluginsEnabled' | 'pluginsUploadEnabled' | 'requirePluginApproval', value: boolean) {
+ setPolicy((prev) => ({ ...prev, features: { ...prev.features, [key]: value } }));
+ setMessage(null);
+ }
+
+ async function savePolicy() {
+ setSavingPolicy(true);
+ setMessage(null);
+ try {
+ const res = await apiFetch('/api/admin/policy', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(policy),
+ });
+ if (res.ok) {
+ setSavedPolicy(policy);
+ setMessage({ type: 'success', text: 'Plugin policy saved. Users see the change on their next login.' });
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not save the plugin policy' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the plugin policy' });
+ } finally {
+ setSavingPolicy(false);
+ }
+ }
+
+ async function handleUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ setMessage(null);
+ const formData = new FormData();
+ formData.append('file', file);
+ try {
+ const res = await apiFetch('/api/admin/plugins', { method: 'POST', body: formData });
+ const data = await res.json();
+ if (res.ok) {
+ const warnings = data.warnings?.length ? ` (${data.warnings.length} warning${data.warnings.length === 1 ? '' : 's'})` : '';
+ setMessage({ type: 'success', text: `"${data.plugin.name}" installed${warnings}` });
+ await fetchPlugins();
+ } else {
+ setMessage({ type: 'error', text: data.error || 'Upload failed' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Upload failed' });
+ } finally {
+ setUploading(false);
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ }
+
+ async function setEnabled(id: string, enabled: boolean): Promise {
+ const res = await apiFetch('/api/admin/plugins', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id, enabled }),
+ });
+ if (res.ok) {
+ setPlugins((prev) => prev.map((p) => (p.id === id ? { ...p, enabled } : p)));
+ return true;
+ }
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not change the plugin' });
+ return false;
+ }
+
+ async function setAll(enabled: boolean) {
+ const targets = plugins.filter((p) => p.enabled !== enabled);
+ if (targets.length === 0) return;
+ setBulkBusy(true);
+ setMessage(null);
+ let failed = 0;
+ for (const p of targets) {
+ if (!(await setEnabled(p.id, enabled))) failed++;
+ }
+ await fetchPlugins();
+ setBulkBusy(false);
+ setMessage(failed === 0
+ ? { type: 'success', text: `${targets.length} plugin${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
+ : { type: 'error', text: `${failed} plugin${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
+ }
+
+ const enabledCount = plugins.filter((p) => p.enabled).length;
+
+ return (
+
+
+
+
+ Marketplace
+
+
+ {uploading ? : }
+ Upload plugin
+
+
+ >
+ )}
+ />
+
+ setMessage(null)} />
+
+
+
+
+
+ {loading ? (
+
+ Loading
+
+ ) : plugins.length === 0 ? (
+ Browse the marketplace}
+ />
+ ) : (
+ <>
+ {plugins.length > 1 && (
+
+
setAll(true)}>
+ Enable all
+
+
setAll(false)}>
+ Disable all
+
+
+ )}
+
+ {plugins.map((plugin) => {
+ const perms = plugin.permissions ?? [];
+ const risky = perms.filter((p) => HIGH_RISK_PERMISSIONS.has(p));
+ return (
+ }
+ title={plugin.name}
+ titleExtra={(
+ <>
+ v{plugin.version}
+ {!plugin.enabled && Off }
+ {plugin.forceEnabled && }>Forced on}
+ {plugin.dev && Dev }
+ {risky.length > 0 && (
+ } title={risky.join(', ')}>
+ {risky.length} high-risk permission{risky.length === 1 ? '' : 's'}
+
+ )}
+ >
+ )}
+ description={plugin.description}
+ meta={(
+ <>
+ by {plugin.author || 'unknown'}
+ {plugin.type && <> · {plugin.type}>}
+ {perms.length > 0 && <> · {perms.length} permission{perms.length === 1 ? '' : 's'}>}
+ {plugin.source?.kind === 'marketplace' && <> · Marketplace>}
+ >
+ )}
+ control={(
+ { setMessage(null); void setEnabled(plugin.id, checked); }}
+ ariaLabel={`${plugin.name} enabled`}
+ />
+ )}
+ />
+ );
+ })}
+
+ >
+ )}
+
+
+
+
+ setFeature('pluginsEnabled', v)} />
+
+
+ setFeature('pluginsUploadEnabled', v)} />
+
+
+ setFeature('requirePluginApproval', v)} />
+
+
+
+
+ setPolicy(savedPolicy)}
+ saveLabel="Save policy"
+ note="Users see the change on their next login"
+ />
+
+ );
+}
diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx
new file mode 100644
index 000000000..e1ae84045
--- /dev/null
+++ b/app/(main)/admin/_pages/policy.tsx
@@ -0,0 +1,629 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
+import { iconForName } from '@/components/icons';
+import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
+import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
+import { apiFetch } from '@/lib/browser-navigation';
+import { adminPageDef } from '@/lib/admin/admin-nav';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
+import { generateUUID, cn } from '@/lib/utils';
+import {
+ DEFAULT_RELAY_BASE_URL,
+ isValidRelayUrl,
+ normalizeRelayUrl,
+ resolveDefaultRelayUrl,
+} from '@/lib/push-relays';
+import { SettingsSection, SettingItem, ToggleSwitch, 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 } from '@/components/admin/config-items';
+
+// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
+// forward on policy load), so it is hidden from the admin UI.
+const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
+
+const FEATURE_GATE_LABELS: Partial> = {
+ sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
+ settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
+ customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
+ templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
+ calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
+ calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
+ contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
+ smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
+ externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
+ debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
+ folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
+ hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
+ tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
+ filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
+ crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
+ crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
+ crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
+ unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
+ accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. Mailbox passwords are stored only when the Passwords option below is on. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
+ accountArchivePasswordsEnabled: { label: 'Account List Backup: Passwords', description: 'With Account List Backup on, offer to include the mailbox passwords of accounts signed in with a password, so a restore signs in to them by itself. They are encrypted in the browser with the backup password together with the list; the server never sees them in clear, but anyone who learns the backup password gets every mailbox in it. Accounts signed in with OAuth or a TOTP code are saved without.' },
+ customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' },
+ archiveActionEnabled: { label: 'Archive Action', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' },
+ tagsAboveSharedEnabled: { label: 'Tags Above Shared Mailboxes', description: 'List the Tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' },
+};
+
+const RESTRICTABLE_SETTINGS = [
+ { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
+ { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
+ { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
+ { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
+ { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
+ { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
+ { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
+ { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
+ { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
+ { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
+ { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
+ { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
+ { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
+ { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] },
+ { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
+ { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
+ { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
+ { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
+ { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' },
+ { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
+];
+
+const PAGE = adminPageDef('policy');
+
+const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground';
+const cardClass = 'rounded-md border border-border bg-muted/30 p-3';
+const subLabelClass = 'mt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground';
+
+/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */
+function isValidDefaultAppUrl(raw: string): boolean {
+ try {
+ const parsed = new URL(raw.trim());
+ return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host;
+ } catch {
+ return false;
+ }
+}
+
+export function PolicyPage() {
+ const [policy, setPolicy] = useState({ ...DEFAULT_POLICY });
+ const [savedPolicy, setSavedPolicy] = useState({ ...DEFAULT_POLICY });
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useFlash();
+ const [dirty, setDirty] = useState(false);
+
+ useEffect(() => { fetchPolicy(); }, []);
+
+ async function fetchPolicy() {
+ setLoading(true);
+ try {
+ const res = await apiFetch('/api/admin/policy');
+ if (res.ok) {
+ const data = await res.json();
+ setPolicy(data);
+ setSavedPolicy(data);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function toggleFeature(key: keyof FeatureGates) {
+ setPolicy(prev => ({
+ ...prev,
+ features: { ...prev.features, [key]: !prev.features[key] },
+ }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function setPushRelayUrl(value: string) {
+ setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function updatePushRelay(index: number, patch: Partial) {
+ setPolicy(prev => ({
+ ...prev,
+ pushRelays: (prev.pushRelays ?? []).map((relay, i) => (i === index ? { ...relay, ...patch } : relay)),
+ }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function addPushRelay() {
+ setPolicy(prev => ({ ...prev, pushRelays: [...(prev.pushRelays ?? []), { label: '', url: '' }] }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function removePushRelay(index: number) {
+ setPolicy(prev => {
+ const remaining = (prev.pushRelays ?? []).filter((_, i) => i !== index);
+ const removedUrl = normalizeRelayUrl((prev.pushRelays ?? [])[index]?.url);
+ // Dropping the relay users are defaulted to would leave a dangling
+ // default, so fall back to the built-in one.
+ const pushRelayUrl = normalizeRelayUrl(prev.pushRelayUrl) === removedUrl ? '' : prev.pushRelayUrl;
+ return { ...prev, pushRelays: remaining, pushRelayUrl };
+ });
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function togglePushRelayLocked() {
+ setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function updateDefaultApp(index: number, patch: Partial) {
+ setPolicy(prev => ({
+ ...prev,
+ defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)),
+ }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function addDefaultApp() {
+ setPolicy(prev => {
+ const apps = prev.defaultSidebarApps ?? [];
+ if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev;
+ return {
+ ...prev,
+ defaultSidebarApps: [
+ ...apps,
+ {
+ id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`,
+ name: '',
+ url: '',
+ icon: 'Globe',
+ openMode: 'tab' as const,
+ showOnMobile: false,
+ },
+ ],
+ };
+ });
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function removeDefaultApp(index: number) {
+ setPolicy(prev => ({
+ ...prev,
+ defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index),
+ }));
+ setDirty(true);
+ setMessage(null);
+ }
+
+ /** Rail order follows this list, so admins need to be able to reorder it. */
+ function moveDefaultApp(index: number, delta: number) {
+ setPolicy(prev => {
+ const apps = [...(prev.defaultSidebarApps ?? [])];
+ const target = index + delta;
+ if (target < 0 || target >= apps.length) return prev;
+ [apps[index], apps[target]] = [apps[target], apps[index]];
+ return { ...prev, defaultSidebarApps: apps };
+ });
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function toggleLocked(settingKey: string) {
+ setPolicy(prev => {
+ const existing = prev.restrictions[settingKey] || {};
+ const newRestrictions = { ...prev.restrictions };
+ if (existing.locked) {
+ delete newRestrictions[settingKey];
+ } else {
+ newRestrictions[settingKey] = { ...existing, locked: true };
+ }
+ return { ...prev, restrictions: newRestrictions };
+ });
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function toggleHidden(settingKey: string) {
+ setPolicy(prev => {
+ const existing = prev.restrictions[settingKey] || {};
+ const newRestrictions = { ...prev.restrictions };
+ newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
+ if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
+ delete newRestrictions[settingKey];
+ }
+ return { ...prev, restrictions: newRestrictions };
+ });
+ setDirty(true);
+ setMessage(null);
+ }
+
+ function discard() {
+ setPolicy(savedPolicy);
+ setDirty(false);
+ setMessage(null);
+ }
+
+ async function handleSave() {
+ setSaving(true);
+ setMessage(null);
+
+ try {
+ const res = await apiFetch('/api/admin/policy', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(policy),
+ });
+
+ if (res.ok) {
+ setSavedPolicy(policy);
+ setMessage({ type: 'success', text: 'Policy saved. Users see the change on their next login.' });
+ setDirty(false);
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not save the policy' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the policy' });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ useAdminUnsavedChanges('policy', dirty);
+
+ const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
+ const defaultRelayUrl = resolveDefaultRelayUrl(policy);
+ const defaultSidebarApps = policy.defaultSidebarApps ?? [];
+
+ return (
+
+
+
+
setMessage(null)} />
+
+ {loading ? (
+
+ Loading
+
+ ) : (
+
+
+
+
+ {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
+ .filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
+ .map(key => {
+ const meta = FEATURE_GATE_LABELS[key];
+ if (!meta) return null;
+ const { label, description } = meta;
+ return (
+
+ toggleFeature(key)} />
+
+ );
+ })}
+
+
+
+
+
+
Relays
+
+ Mark one as the default. Relays without a valid URL are not offered to users.
+
+
+
+
+ Add relay
+
+
+
+
+
+
+
+ Bulwark relay
+ built-in
+
+
{DEFAULT_RELAY_BASE_URL}
+
+
+ setPushRelayUrl('')}
+ className="border-input"
+ />
+ Default
+
+
+
+
+ {(policy.pushRelays ?? []).map((relay, index) => (
+
+
+
+ Name
+ updatePushRelay(index, { label: e.target.value })}
+ placeholder="Company relay"
+ className={cn(inputClass, 'w-full')}
+ />
+
+
+
Relay URL
+
+ updatePushRelay(index, { url: e.target.value })}
+ placeholder="https://notifications.relay.example.com"
+ className={cn(inputClass, 'w-full')}
+ />
+ removePushRelay(index)}
+ className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
+ title="Remove relay"
+ aria-label="Remove relay"
+ >
+
+
+
+
+
+
+ setPushRelayUrl(normalizeRelayUrl(relay.url))}
+ className="border-input disabled:opacity-40"
+ />
+ Default
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {categories.map(category => (
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx
new file mode 100644
index 000000000..987ea6333
--- /dev/null
+++ b/app/(main)/admin/_pages/settings.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Loader2 } from '@/components/icons';
+import { apiFetch } from '@/lib/browser-navigation';
+import { adminPageDef } from '@/lib/admin/admin-nav';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { AdminSaveBar } from '@/components/admin/save-bar';
+import { Flash, useFlash } from '@/components/admin/flash-message';
+import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items';
+import { JmapServersSection } from './_jmap-servers-section';
+import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
+
+interface ConfigEntry {
+ value?: unknown;
+ source: 'admin' | 'env' | 'default';
+ hasValue?: boolean;
+}
+
+const PAGE = adminPageDef('settings');
+
+export function SettingsPage() {
+ const [config, setConfig] = useState>({});
+ const [edits, setEdits] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useFlash();
+
+ useEffect(() => {
+ fetchConfig();
+ }, []);
+
+ async function fetchConfig() {
+ setLoading(true);
+ const res = await apiFetch('/api/admin/config');
+ if (res.ok) {
+ setConfig(await res.json());
+ }
+ setLoading(false);
+ }
+
+ function handleChange(key: string, value: unknown) {
+ setEdits(prev => ({ ...prev, [key]: value }));
+ setMessage(null);
+ }
+
+ function currentValue(key: string): unknown {
+ if (key in edits) return edits[key];
+ return config[key]?.value;
+ }
+
+ async function handleSave() {
+ if (Object.keys(edits).length === 0) return;
+ setSaving(true);
+ setMessage(null);
+
+ const res = await apiFetch('/api/admin/config', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(edits),
+ });
+
+ if (res.ok) {
+ setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
+ setEdits({});
+ await fetchConfig();
+ } else {
+ const data = await res.json();
+ setMessage({ type: 'error', text: data.error || 'Failed to save' });
+ }
+ setSaving(false);
+ }
+
+ async function handleRevert(key: string) {
+ const res = await apiFetch('/api/admin/config', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key }),
+ });
+ if (res.ok) {
+ setEdits(prev => {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ await fetchConfig();
+ setMessage({ type: 'success', text: `${key} reverted to default` });
+ }
+ }
+
+ const hasEdits = Object.keys(edits).length > 0;
+
+ useAdminUnsavedChanges('settings', hasEdits);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ const jmapServers = currentValue('jmapServers');
+
+ return (
+
+
+
+
setMessage(null)} />
+
+
+
+
+
+
+
+
+ {!!currentValue('allowCustomJmapEndpoint') && (
+
+ CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleChange('jmapServers', next)}
+ onRevert={() => handleRevert('jmapServers')}
+ />
+ {Array.isArray(jmapServers) && (jmapServers as JmapServerEntry[]).length > 0 && (
+
+ CORS warning: Each JMAP server must allow this webmail's origin in its Access-Control-Allow-Origin header, or browser requests will be blocked.
+
+ )}
+
+
+
+
+
+
+ Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail's base path is added to it. The editor must also allow this webmail as a WOPI host.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setEdits({})}
+ note="Takes effect on the next page load"
+ />
+
+ );
+}
diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx
new file mode 100644
index 000000000..a745ac45c
--- /dev/null
+++ b/app/(main)/admin/_pages/telemetry.tsx
@@ -0,0 +1,251 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Loader2, Send, ExternalLink } from '@/components/icons';
+import { SettingsSection, SettingItem, ToggleSwitch } 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 { Flash, useFlash } from '@/components/admin/flash-message';
+import { ConfigNote, inputClass } from '@/components/admin/config-items';
+import { adminPageDef } from '@/lib/admin/admin-nav';
+import { apiFetch } from '@/lib/browser-navigation';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { cn } from '@/lib/utils';
+
+interface TelemetryStatus {
+ consent: 'pending' | 'on' | 'off';
+ consentSource: 'env' | 'file';
+ endpoint: string;
+ defaultEndpoint: string;
+ consentedAt: string | null;
+ lastSentAt: string | null;
+ nextScheduledAt: string | null;
+ payloadPreview: Record;
+ accountCounts: { total: number; active7d: number };
+}
+
+const PAGE = adminPageDef('telemetry');
+
+function timeAgo(iso: string | null): string {
+ if (!iso) return 'never';
+ const d = Date.now() - new Date(iso).getTime();
+ if (d < 0) return new Date(iso).toLocaleString();
+ const m = Math.floor(d / 60000);
+ if (m < 1) return 'just now';
+ if (m < 60) return `${m} min ago`;
+ const h = Math.floor(m / 60);
+ if (h < 48) return `${h} hours ago`;
+ const days = Math.floor(h / 24);
+ return `${days} days ago`;
+}
+
+export function TelemetryPage() {
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(null);
+ const [endpointDraft, setEndpointDraft] = useState('');
+ const [message, setMessage] = useFlash();
+
+ const endpointDirty = status !== null && endpointDraft !== status.endpoint;
+ useAdminUnsavedChanges('telemetry', endpointDirty);
+
+ async function refresh(): Promise {
+ setLoading(true);
+ try {
+ const r = await apiFetch('/api/admin/telemetry');
+ if (!r.ok) throw new Error('failed to load');
+ const data = (await r.json()) as TelemetryStatus;
+ setStatus(data);
+ setEndpointDraft(data.endpoint);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+ useEffect(() => { void refresh(); }, []);
+
+ async function setConsent(consent: 'on' | 'off'): Promise {
+ setBusy('consent');
+ setMessage(null);
+ try {
+ const r = await apiFetch('/api/admin/telemetry', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ action: 'set-consent', consent }),
+ });
+ if (!r.ok) {
+ const j = (await r.json().catch(() => ({}))) as { error?: string };
+ setMessage({ type: 'error', text: j.error ?? 'failed' });
+ }
+ await refresh();
+ } finally { setBusy(null); }
+ }
+
+ async function saveEndpoint(): Promise {
+ setBusy('endpoint');
+ setMessage(null);
+ try {
+ const r = await apiFetch('/api/admin/telemetry', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
+ });
+ if (!r.ok) {
+ const j = (await r.json().catch(() => ({}))) as { error?: string };
+ setMessage({ type: 'error', text: j.error ?? 'failed' });
+ } else {
+ setMessage({ type: 'success', text: 'Endpoint saved.' });
+ }
+ await refresh();
+ } finally { setBusy(null); }
+ }
+
+ async function sendNow(): Promise {
+ setBusy('send');
+ setMessage(null);
+ try {
+ const r = await apiFetch('/api/admin/telemetry', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ action: 'send-now' }),
+ });
+ const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
+ setMessage(j.ok
+ ? { type: 'success', text: `sent (HTTP ${j.status ?? '?'})` }
+ : { type: 'error', text: `failed: ${j.error ?? 'unknown'}` });
+ await refresh();
+ } finally { setBusy(null); }
+ }
+
+ const header = ;
+
+ if (loading || !status) {
+ return (
+
+ {header}
+
+ Loading
+
+
+ );
+ }
+
+ const envOverridden = status.consentSource === 'env';
+ const isOn = status.consent === 'on';
+
+ const consentDescription = [
+ status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
+ status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!',
+ status.consent === 'off' && 'Heartbeats are off (default).',
+ envOverridden && 'Locked by the BULWARK_TELEMETRY env var.',
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {header}
+
+
setMessage(null)} />
+
+
+
+ Bulwark can send one anonymous heartbeat per day so we can see how many instances are
+ running, on what platforms, and which features they use. It's off by
+ default ; one click below enables it and helps us make the product better. No
+ email addresses, no hostnames, no IPs are sent.{' '}
+
+ Full schema and policy
+
+
+
+
+
+ void setConsent(checked ? 'on' : 'off')}
+ />
+
+
+ {timeAgo(status.lastSentAt)}
+
+
+ {timeAgo(status.nextScheduledAt)}
+
+
+ {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
+
+
+
+
+
+ {status.accountCounts?.total ?? 0}
+
+
+ {status.accountCounts?.active7d ?? 0}
+
+
+
+
+
+ { setEndpointDraft(e.target.value); setMessage(null); }}
+ placeholder={status.defaultEndpoint}
+ aria-label="Collector URL"
+ autoComplete="off"
+ spellCheck={false}
+ className={cn(inputClass, 'w-full sm:w-80')}
+ />
+
+
+
+
+
+ void sendNow()}
+ >
+ {busy === 'send' ? : }
+ Send now
+
+
+
+ {JSON.stringify(status.payloadPreview, null, 2)}
+
+
+
+
+ void saveEndpoint()}
+ onDiscard={() => setEndpointDraft(status.endpoint)}
+ saveLabel="Save endpoint"
+ />
+
+ );
+}
diff --git a/app/(main)/admin/_pages/themes.tsx b/app/(main)/admin/_pages/themes.tsx
new file mode 100644
index 000000000..e50896f7a
--- /dev/null
+++ b/app/(main)/admin/_pages/themes.tsx
@@ -0,0 +1,404 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Upload, Loader2, SwatchBook, Lock, Store, Power, PowerOff } from '@/components/icons';
+import type { ColorMode, SettingsPolicy, ThemePolicy } from '@/lib/admin/types';
+import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
+import { apiFetch } from '@/lib/browser-navigation';
+import { adminPath, adminPageDef } from '@/lib/admin/admin-nav';
+import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
+import { BUILTIN_THEMES } from '@/lib/builtin-themes';
+import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { AdminButtonLink } from '@/components/admin/button-link';
+import { AdminList, AdminListRow } from '@/components/admin/list';
+import { AdminSaveBar } from '@/components/admin/save-bar';
+import { Badge } from '@/components/admin/badge';
+import { EmptyState } from '@/components/admin/empty-state';
+import { Flash, useFlash } from '@/components/admin/flash-message';
+import { inputClass } from '@/components/admin/config-items';
+import { cn } from '@/lib/utils';
+import { ExtensionIcon } from '@/components/admin/extension-icon';
+import { useExtensionIcons } from './use-extension-icons';
+import type { ExtensionEntry } from './extension-detail';
+
+// Derive from the single source of truth so newly added built-in themes show
+// up here automatically (was previously a hardcoded subset — see #496).
+const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
+
+const PAGE = adminPageDef('themes');
+
+/** Policy as the page edits it: the theme policy is always fully merged. */
+function withThemeDefaults(data: SettingsPolicy): SettingsPolicy {
+ return {
+ ...data,
+ themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
+ };
+}
+
+export function ThemesPage() {
+ const [themes, setThemes] = useState([]);
+ const iconFor = useExtensionIcons();
+ const [loading, setLoading] = useState(true);
+ const [uploading, setUploading] = useState(false);
+ const [bulkBusy, setBulkBusy] = useState(false);
+ const [message, setMessage] = useFlash();
+ const fileInputRef = useRef(null);
+ const [policy, setPolicy] = useState(() => withThemeDefaults({ ...DEFAULT_POLICY }));
+ const [savedPolicy, setSavedPolicy] = useState(() => withThemeDefaults({ ...DEFAULT_POLICY }));
+ const [savingPolicy, setSavingPolicy] = useState(false);
+
+ const fetchThemes = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/themes');
+ if (res.ok) setThemes(await res.json());
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchPolicy = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/policy');
+ if (res.ok) {
+ const data = withThemeDefaults(await res.json());
+ setPolicy(data);
+ setSavedPolicy(data);
+ }
+ } catch { /* the defaults stay */ }
+ }, []);
+
+ useEffect(() => { fetchThemes(); fetchPolicy(); }, [fetchThemes, fetchPolicy]);
+
+ const policyDirty =
+ JSON.stringify(policy.themePolicy) !== JSON.stringify(savedPolicy.themePolicy) ||
+ JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
+ useAdminUnsavedChanges('themes', policyDirty);
+
+ function setFeature(key: 'themesEnabled' | 'userThemesEnabled', value: boolean) {
+ setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: value } }));
+ setMessage(null);
+ }
+
+ function toggleBuiltinTheme(themeId: string) {
+ setPolicy(prev => {
+ const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
+ const isDisabled = disabled.includes(themeId);
+ return {
+ ...prev,
+ themePolicy: {
+ ...DEFAULT_THEME_POLICY,
+ ...prev.themePolicy,
+ disabledBuiltinThemes: isDisabled
+ ? disabled.filter((id: string) => id !== themeId)
+ : [...disabled, themeId],
+ },
+ };
+ });
+ setMessage(null);
+ }
+
+ function toggleAdminTheme(themeId: string) {
+ setPolicy(prev => {
+ const disabled = prev.themePolicy?.disabledThemes || [];
+ const isDisabled = disabled.includes(themeId);
+ return {
+ ...prev,
+ themePolicy: {
+ ...DEFAULT_THEME_POLICY,
+ ...prev.themePolicy,
+ disabledThemes: isDisabled
+ ? disabled.filter((id: string) => id !== themeId)
+ : [...disabled, themeId],
+ },
+ };
+ });
+ setMessage(null);
+ }
+
+ function setDefaultTheme(themeId: string | null) {
+ setPolicy(prev => ({
+ ...prev,
+ themePolicy: {
+ ...DEFAULT_THEME_POLICY,
+ ...prev.themePolicy,
+ defaultThemeId: themeId,
+ },
+ }));
+ setMessage(null);
+ }
+
+ function setColorModePolicy(patch: Partial>) {
+ setPolicy(prev => ({
+ ...prev,
+ themePolicy: {
+ ...DEFAULT_THEME_POLICY,
+ ...prev.themePolicy,
+ ...patch,
+ },
+ }));
+ setMessage(null);
+ }
+
+ async function savePolicy() {
+ setSavingPolicy(true);
+ setMessage(null);
+ try {
+ const res = await apiFetch('/api/admin/policy', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(policy),
+ });
+ if (res.ok) {
+ setSavedPolicy(policy);
+ setMessage({ type: 'success', text: 'Theme policy saved. Users see the change on their next login.' });
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not save the theme policy' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the theme policy' });
+ } finally {
+ setSavingPolicy(false);
+ }
+ }
+
+ async function handleUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ setMessage(null);
+ const formData = new FormData();
+ formData.append('file', file);
+ try {
+ const res = await apiFetch('/api/admin/themes', { method: 'POST', body: formData });
+ const data = await res.json();
+ if (res.ok) {
+ const warnings = data.warnings?.length ? ` (${data.warnings.length} warning${data.warnings.length === 1 ? '' : 's'})` : '';
+ setMessage({ type: 'success', text: `"${data.theme.name}" installed${warnings}` });
+ await fetchThemes();
+ } else {
+ setMessage({ type: 'error', text: data.error || 'Upload failed' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Upload failed' });
+ } finally {
+ setUploading(false);
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ }
+
+ async function setEnabled(id: string, enabled: boolean): Promise {
+ const res = await apiFetch('/api/admin/themes', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id, enabled }),
+ });
+ if (res.ok) {
+ setThemes(prev => prev.map(t => (t.id === id ? { ...t, enabled } : t)));
+ return true;
+ }
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not change the theme' });
+ return false;
+ }
+
+ async function setAll(enabled: boolean) {
+ const targets = themes.filter(t => t.enabled !== enabled);
+ if (targets.length === 0) return;
+ setBulkBusy(true);
+ setMessage(null);
+ let failed = 0;
+ for (const t of targets) {
+ if (!(await setEnabled(t.id, enabled))) failed++;
+ }
+ await fetchThemes();
+ setBulkBusy(false);
+ setMessage(failed === 0
+ ? { type: 'success', text: `${targets.length} theme${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
+ : { type: 'error', text: `${failed} theme${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
+ }
+
+ const enabledCount = themes.filter(t => t.enabled).length;
+ const themesEnabled = policy.features.themesEnabled ?? true;
+ const userThemesEnabled = policy.features.userThemesEnabled ?? true;
+ const lockColorMode = policy.themePolicy?.lockColorMode === true;
+ const disabledBuiltin = policy.themePolicy?.disabledBuiltinThemes || [];
+ const disabledInstalled = policy.themePolicy?.disabledThemes || [];
+
+ return (
+
+
+
+
+ Marketplace
+
+
+ {uploading ? : }
+ Upload theme
+
+
+ >
+ )}
+ />
+
+ setMessage(null)} />
+
+
+
+ {loading ? (
+
+ Loading
+
+ ) : themes.length === 0 ? (
+ Browse the marketplace}
+ />
+ ) : (
+ <>
+ {themes.length > 1 && (
+
+
setAll(true)}>
+ Enable all
+
+
setAll(false)}>
+ Disable all
+
+
+ )}
+
+ {themes.map(theme => (
+ }
+ title={theme.name}
+ titleExtra={(
+ <>
+ v{theme.version}
+ {!theme.enabled && Off }
+ {theme.forceEnabled && }>Forced on}
+ >
+ )}
+ description={theme.description}
+ meta={(
+ <>
+ by {theme.author || 'unknown'}
+ {theme.variants && theme.variants.length > 0 && <> · {theme.variants.join(', ')}>}
+ {theme.source?.kind === 'marketplace' && <> · Marketplace>}
+ >
+ )}
+ control={(
+ { setMessage(null); void setEnabled(theme.id, checked); }}
+ ariaLabel={`${theme.name} enabled`}
+ />
+ )}
+ />
+ ))}
+
+ >
+ )}
+
+
+
+
+ setFeature('themesEnabled', v)} />
+
+
+ setFeature('userThemesEnabled', v)} />
+
+
+ setDefaultTheme(e.target.value || null)}
+ aria-label="Default theme"
+ className={cn(inputClass, 'w-full pe-8 cursor-pointer sm:w-auto')}
+ >
+ System default
+
+ {BUILTIN_THEME_OPTIONS
+ .filter(t => !disabledBuiltin.includes(t.id))
+ .map(t => (
+ {t.name}
+ ))}
+
+ {themes.length > 0 && (
+
+ {themes
+ .filter(t => !disabledInstalled.includes(t.id))
+ .map(t => (
+ {t.name}
+ ))}
+
+ )}
+
+
+
+ setColorModePolicy({ defaultColorMode: e.target.value === 'system' ? null : e.target.value as ColorMode })}
+ aria-label="Default colour mode"
+ className={cn(inputClass, 'w-full pe-8 cursor-pointer sm:w-auto')}
+ >
+ System
+ Light
+ Dark
+
+
+
+ setColorModePolicy({ lockColorMode: v })} />
+
+
+
+
+ {BUILTIN_THEME_OPTIONS.map(theme => (
+
+ toggleBuiltinTheme(theme.id)} />
+
+ ))}
+ {themes.length > 0 && (
+ <>
+ Installed
+ {themes.map(theme => (
+
+ toggleAdminTheme(theme.id)} />
+
+ ))}
+ >
+ )}
+
+
+
+ setPolicy(savedPolicy)}
+ saveLabel="Save policy"
+ note="Users see the change on their next login"
+ />
+
+ );
+}
diff --git a/app/(main)/admin/_pages/use-extension-icons.ts b/app/(main)/admin/_pages/use-extension-icons.ts
new file mode 100644
index 000000000..de4978d59
--- /dev/null
+++ b/app/(main)/admin/_pages/use-extension-icons.ts
@@ -0,0 +1,53 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { MarketplaceListing } from '@/lib/admin/marketplace-directory';
+import type { ExtensionEntry } from './extension-detail';
+
+/**
+ * Brand icons for installed plugins and themes. An installed bundle carries
+ * no image (the manifest's `icon` is marketplace media the directory
+ * serves), so the icons come from the directory's listing, matched by slug.
+ * Fetched once per session; an unreachable directory just means the
+ * generic icons stay.
+ */
+let cache: Map | null = null;
+let pending: Promise> | null = null;
+
+async function loadIcons(): Promise> {
+ const icons = new Map();
+ for (let page = 1; page <= 5; page++) {
+ const res = await apiFetch(`/api/admin/marketplace?page=${page}&perPage=100`);
+ if (!res.ok) break;
+ const data = await res.json() as { data?: MarketplaceListing[]; meta?: { total?: number; perPage?: number } };
+ for (const ext of data.data ?? []) {
+ if (ext.iconUrl) icons.set(ext.slug, ext.iconUrl);
+ }
+ const seen = page * (data.meta?.perPage ?? 100);
+ if (!data.data?.length || seen >= (data.meta?.total ?? 0)) break;
+ }
+ return icons;
+}
+
+export function useExtensionIcons(): (entry: Pick) => string | null {
+ const [icons, setIcons] = useState | null>(cache);
+ useEffect(() => {
+ if (cache) return;
+ let cancelled = false;
+ pending ??= loadIcons().then((map) => { cache = map; return map; }).catch(() => new Map());
+ pending.then((map) => { if (!cancelled) setIcons(map); });
+ return () => { cancelled = true; };
+ }, []);
+ return (entry) => {
+ if (!icons) return null;
+ const slug = entry.source?.kind === 'marketplace' ? entry.source.slug : entry.id;
+ return icons.get(slug) ?? null;
+ };
+}
+
+/** Test hook: forget what was fetched. */
+export function resetExtensionIconCache(): void {
+ cache = null;
+ pending = null;
+}
diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx
new file mode 100644
index 000000000..e9dcd6d0c
--- /dev/null
+++ b/app/(main)/admin/_pages/version.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import {
+ Loader2,
+ RefreshCw,
+ CheckCircle2,
+ AlertTriangle,
+ ShieldAlert,
+ ExternalLink,
+} from '@/components/icons';
+import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
+import { Button } from '@/components/ui/button';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { Badge, type BadgeTone } from '@/components/admin/badge';
+import { Flash, useFlash } from '@/components/admin/flash-message';
+import { adminPageDef } from '@/lib/admin/admin-nav';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
+
+interface VersionAdminStatus {
+ current: string;
+ build: string;
+ endpoint: string;
+ defaultEndpoint: string;
+ disabledByEnv: boolean;
+ lastCheckedAt: string | null;
+ lastSuccessAt: string | null;
+ nextScheduledAt: string | null;
+ status: UpdateStatus | null;
+}
+
+const PAGE = adminPageDef('version');
+
+function timeAgo(iso: string | null): string {
+ if (!iso) return 'never';
+ const d = Date.now() - new Date(iso).getTime();
+ if (d < 0) return new Date(iso).toLocaleString();
+ const m = Math.floor(d / 60000);
+ if (m < 1) return 'just now';
+ if (m < 60) return `${m} min ago`;
+ const h = Math.floor(m / 60);
+ if (h < 48) return `${h} hours ago`;
+ return `${Math.floor(h / 24)} days ago`;
+}
+
+function severityChip(severity: UpdateSeverity): { label: string; tone: BadgeTone; Icon: typeof CheckCircle2 } {
+ switch (severity) {
+ case 'security':
+ return { label: 'Security update', tone: 'danger', Icon: ShieldAlert };
+ case 'deprecated':
+ return { label: 'Deprecated', tone: 'danger', Icon: ShieldAlert };
+ case 'normal':
+ return { label: 'Update available', tone: 'warning', Icon: AlertTriangle };
+ case 'unknown':
+ return { label: 'Unknown', tone: 'muted', Icon: AlertTriangle };
+ case 'none':
+ default:
+ return { label: 'Up to date', tone: 'success', Icon: CheckCircle2 };
+ }
+}
+
+export function VersionPage() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [checking, setChecking] = useState(false);
+ const [message, setMessage] = useFlash();
+
+ async function refresh(): Promise {
+ setLoading(true);
+ try {
+ const r = await apiFetch('/api/admin/version');
+ if (!r.ok) throw new Error('failed to load');
+ setData((await r.json()) as VersionAdminStatus);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+ useEffect(() => { void refresh(); }, []);
+
+ async function checkNow(): Promise {
+ setChecking(true);
+ setMessage(null);
+ try {
+ const r = await apiFetch('/api/admin/version', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ action: 'check-now' }),
+ });
+ const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
+ setMessage(j.ok
+ ? { type: 'success', text: 'Update check completed.' }
+ : { type: 'error', text: `Failed: ${j.error ?? 'unknown'}` });
+ await refresh();
+ } finally {
+ setChecking(false);
+ }
+ }
+
+ const header = (
+ void checkNow()}>
+ {checking ? : }
+ Check now
+
+ )}
+ />
+ );
+
+ if (loading || !data) {
+ return (
+
+ {header}
+
+ Loading
+
+
+ );
+ }
+
+ const status = data.status;
+ const chip = severityChip(status?.severity ?? 'none');
+ const ChipIcon = chip.Icon;
+ const releaseUrl = status?.url ?? null;
+ const newer = status?.latest && status.latest !== data.current ? status.latest : null;
+
+ return (
+
+ {header}
+
+
setMessage(null)} />
+
+
+
+
+ }>{chip.label}
+
+
+ {data.current}
+
+ {newer && (
+
+ {releaseUrl ? (
+
+ {newer}
+
+ ) : (
+ {newer}
+ )}
+
+ )}
+ {status?.advisory && (
+
+ {status.advisory}
+
+ )}
+
+
+
+
+ {timeAgo(data.lastCheckedAt)}
+
+
+ {timeAgo(data.lastSuccessAt)}
+
+
+ {timeAgo(data.nextScheduledAt)}
+
+ {status?.checkedAt && (
+
+ {new Date(status.checkedAt).toLocaleString()}
+
+ )}
+
+
+
+
+
+ {data.endpoint}
+
+
+
+
+ {data.disabledByEnv ? 'Yes' : 'No'}
+
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
deleted file mode 100644
index 2b3b977b1..000000000
--- a/app/(main)/admin/_tabs/auth.tsx
+++ /dev/null
@@ -1,407 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons';
-import { apiFetch } from '@/lib/browser-navigation';
-import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
-
-interface ConfigEntry {
- // Sensitive keys (sessionSecret, oauthClientSecret) come back with
- // `value` omitted and `hasValue` set instead - the server never echoes
- // the raw secret to the client.
- value?: unknown;
- source: 'admin' | 'env' | 'default';
- hasValue?: boolean;
-}
-
-export function AuthTab() {
- const [config, setConfig] = useState>({});
- const [edits, setEdits] = useState>({});
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
-
- useEffect(() => { fetchConfig(); }, []);
-
- async function fetchConfig() {
- setLoading(true);
- const res = await apiFetch('/api/admin/config');
- if (res.ok) setConfig(await res.json());
- setLoading(false);
- }
-
- function handleChange(key: string, value: unknown) {
- setEdits(prev => ({ ...prev, [key]: value }));
- setMessage(null);
- }
-
- function currentValue(key: string): unknown {
- if (key in edits) return edits[key];
- return config[key]?.value;
- }
-
- async function handleSave() {
- if (Object.keys(edits).length === 0) return;
- setSaving(true);
- setMessage(null);
-
- const res = await apiFetch('/api/admin/config', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(edits),
- });
-
- if (res.ok) {
- setMessage({ type: 'success', text: 'Authentication settings saved.' });
- setEdits({});
- await fetchConfig();
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Failed to save' });
- }
- setSaving(false);
- }
-
- async function handleRevert(key: string) {
- const res = await apiFetch('/api/admin/config', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key }),
- });
- if (res.ok) {
- setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
- await fetchConfig();
- }
- }
-
- const [setupRunning, setSetupRunning] = useState(false);
- const [setupOpen, setSetupOpen] = useState(false);
- const [setupOrigin, setSetupOrigin] = useState('');
- const [setupIssuer, setSetupIssuer] = useState('');
- const [setupOauthOnly, setSetupOauthOnly] = useState(false);
-
- function openSetupDialog() {
- if (typeof window === 'undefined') return;
- const origin = window.location.origin;
- const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
- setSetupOrigin(origin);
- setSetupIssuer(jmapUrl || origin);
- setSetupOauthOnly(currentValue('oauthOnly') === true);
- setSetupOpen(true);
- }
-
- async function handleAutoSetup() {
- setSetupRunning(true);
- setMessage(null);
- try {
- const res = await apiFetch('/api/admin/oauth/setup', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- origin: setupOrigin.trim().replace(/\/+$/, ''),
- issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
- oauthOnly: setupOauthOnly,
- }),
- });
- const data = await res.json();
- if (res.ok) {
- setMessage({
- type: 'success',
- text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
- });
- setEdits({});
- setSetupOpen(false);
- await fetchConfig();
- } else {
- const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
- setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
- }
- } catch (err) {
- setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
- } finally {
- setSetupRunning(false);
- }
- }
-
- const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
- const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
-
- const hasEdits = Object.keys(edits).length > 0;
-
- if (loading) {
- return Loading...
;
- }
-
- return (
-
-
-
-
Authentication
-
OAuth, SSO, and session configuration
-
- {hasEdits && (
-
- {saving ? : }
- Save changes
-
- )}
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
-
-
-
Auto-configure OAuth (Stalwart)
-
-
- Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
- Requires your Stalwart account to have admin permissions.
-
-
-
- {setupRunning ? : }
- {setupRunning ? 'Configuring…' : 'Set up automagically'}
-
-
-
-
- {setupOpen && (
-
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
- >
-
-
-
Auto-configure OAuth
-
- Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
-
-
-
-
-
- Webmail origin
-
-
setSetupOrigin(e.target.value)}
- disabled={setupRunning}
- placeholder="https://webmail.example.com"
- className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- />
-
- Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart.
-
- {!setupOriginValid && setupOrigin.length > 0 && (
-
Must be like https://host with no path.
- )}
-
-
-
- Stalwart issuer URL
-
-
setSetupIssuer(e.target.value)}
- disabled={setupRunning}
- placeholder="https://mail.example.com"
- className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- />
-
- Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL.
-
- {!setupIssuerValid && setupIssuer.length > 0 && (
-
Must be like https://host with no path.
- )}
-
-
- setSetupOauthOnly(e.target.checked)}
- className="h-3.5 w-3.5 rounded border-input"
- disabled={setupRunning}
- />
- Also enable “OAuth only” (hide password login)
-
-
-
- setSetupOpen(false)}
- disabled={setupRunning}
- className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
- >
- Cancel
-
-
- {setupRunning ? : }
- {setupRunning ? 'Configuring…' : 'Configure'}
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
- );
-}
-
-function Section({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
-
-
{title}
-
-
{children}
-
- );
-}
-
-function SourceBadge({ source }: { source?: string }) {
- if (!source || source === 'default') return null;
- return (
-
- {source}
-
- );
-}
-
-function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
- label: string; description?: string; configKey: string; value: string; source?: string;
- onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
-}) {
- return (
-
-
-
- {label}
-
-
- {description &&
{description}
}
-
-
- onChange(configKey, e.target.value)} placeholder={placeholder}
- className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
- {source === 'admin' && (
- onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert">
- )}
-
-
- );
-}
-
-function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
- label: string; description?: string; configKey: string; value: boolean; source?: string;
- onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
-}) {
- return (
-
-
-
- {label}
-
-
- {description &&
{description}
}
-
-
- onChange(configKey, !value)}
- className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
-
-
- {source === 'admin' && (
- onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert">
- )}
-
-
- );
-}
-
-function Select({ label, description, configKey, value, source, options, optionLabels, onChange, onRevert }: {
- label: string; description?: string; configKey: string; value: string; source?: string; options: string[];
- optionLabels?: Record;
- onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
-}) {
- return (
-
-
-
- {label}
-
-
- {description &&
{description}
}
-
-
- onChange(configKey, e.target.value)}
- className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
- {options.map(o => {optionLabels?.[o] ?? o} )}
-
- {source === 'admin' && (
- onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert">
- )}
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx
deleted file mode 100644
index c93da7367..000000000
--- a/app/(main)/admin/_tabs/branding.tsx
+++ /dev/null
@@ -1,721 +0,0 @@
-'use client';
-
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons';
-import { apiFetch, withBasePath } from '@/lib/browser-navigation';
-import {
- BRANDING_OVERRIDE_KEYS,
- parseDomainBranding,
- type BrandingOverrideKey,
- type DomainBrandingEntry,
-} from '@/lib/admin/domain-branding';
-
-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: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
- { key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
- { key: 'pwaScreenshotDesktopUrl', label: 'PWA 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 Color', defaultValue: '#ffffff' },
- { key: 'pwaBackgroundColor', label: 'Background Color', 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])?)*$/;
-
-export function BrandingTab() {
- const [config, setConfig] = useState>({});
- const [edits, setEdits] = useState>({});
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [uploading, setUploading] = useState(null);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
- const [selectedHost, setSelectedHost] = useState(null);
- const [addingHost, setAddingHost] = useState(false);
- const [newHostInput, setNewHostInput] = useState('');
- const [newHostError, setNewHostError] = useState(null);
- const fileInputRefs = useRef>({});
-
- 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);
-
- if (loading) {
- return Loading...
;
- }
-
- return (
-
-
-
-
Branding
-
Customize logos, favicon, and company information
-
- {hasEdits && (
-
- {saving ? : }
- Save changes
-
- )}
-
-
- {/* Scope picker */}
-
-
-
-
Scope
-
-
-
-
handleScopeChange(null)}
- className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
- selectedHost === null
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-foreground hover:bg-muted/70'
- }`}
- >
- Default
-
- {domainEntries.map(entry => (
-
handleScopeChange(entry.host)}
- className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
- selectedHost === entry.host
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-foreground hover:bg-muted/70'
- }`}
- >
- {entry.host}
-
- ))}
- {!addingHost && (
-
{ setAddingHost(true); setNewHostError(null); }}
- className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
- >
-
- Add domain
-
- )}
-
- {addingHost && (
-
- { setNewHostInput(e.target.value); setNewHostError(null); }}
- onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
- placeholder="mail.example.com or *.example.com"
- className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- />
-
- Add
-
- { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
- className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
- >
- Cancel
-
- {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.'}
-
-
-
- Remove domain
-
-
- ) : (
-
- Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
-
- )}
-
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
Images & Logos
-
Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)
-
-
- {IMAGE_FIELDS.map(field => (
-
-
- {currentValue(field.key) && (
-
-
-
-
{ (e.target as HTMLImageElement).style.display = 'none'; }}
- />
-
-
- )}
-
- ))}
-
-
-
-
-
-
Progressive Web App
-
Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.
-
-
- {PWA_IMAGE_FIELDS.map(field => (
-
-
- {currentValue(field.key) && (
-
-
-
-
{ (e.target as HTMLImageElement).style.display = 'none'; }}
- />
-
-
- )}
-
- ))}
- {PWA_TEXT_FIELDS.map(field => (
-
-
- {field.label}
- {isOverriddenInScope(field.key) && (
-
- {selectedHost ? 'domain' : 'admin'}
-
- )}
-
-
- handleChange(field.key, e.target.value)}
- placeholder={field.placeholder}
- className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- />
- {isOverriddenInScope(field.key) && (
- handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
-
-
- )}
-
-
- ))}
- {PWA_COLOR_FIELDS.map(field => {
- const value = currentValue(field.key) || field.defaultValue;
- return (
-
- );
- })}
-
-
-
-
-
-
Company Information
-
-
- {TEXT_FIELDS.map(field => (
-
-
- {field.label}
- {isOverriddenInScope(field.key) && (
-
- {selectedHost ? 'domain' : 'admin'}
-
- )}
-
-
- handleChange(field.key, e.target.value)}
- placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
- className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- />
- {isOverriddenInScope(field.key) && (
- handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
-
-
- )}
-
-
- ))}
-
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/dashboard.tsx b/app/(main)/admin/_tabs/dashboard.tsx
deleted file mode 100644
index 3356c8d9e..000000000
--- a/app/(main)/admin/_tabs/dashboard.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { AlertTriangle } from '@/components/icons';
-import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
-import type { AuditEntry } from '@/lib/admin/types';
-import { apiFetch } from '@/lib/browser-navigation';
-
-interface AdminStatus {
- enabled: boolean;
- authenticated: boolean;
- lastLogin: string | null;
- passwordChangedAt: string | null;
-}
-
-interface ConfigData {
- appName?: string;
- jmapServerUrl?: string;
- settingsSyncEnabled?: boolean;
- stalwartFeaturesEnabled?: boolean;
- oauthEnabled?: boolean;
- devMode?: boolean;
-}
-
-export function DashboardTab() {
- const [status, setStatus] = useState(null);
- const [recentActivity, setRecentActivity] = useState([]);
- const [config, setConfig] = useState(null);
- const [, setConfigSources] = useState | null>(null);
- const [warnings, setWarnings] = useState([]);
- const [pluginCount, setPluginCount] = useState(0);
- const [themeCount, setThemeCount] = useState(0);
- const [policyRuleCount, setPolicyRuleCount] = useState(0);
- const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
-
- useEffect(() => {
- fetchDashboardData();
- }, []);
-
- async function fetchDashboardData() {
- const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
- apiFetch('/api/admin/auth'),
- apiFetch('/api/admin/audit?limit=10'),
- apiFetch('/api/config'),
- apiFetch('/api/admin/config'),
- apiFetch('/api/admin/plugins').catch(() => null),
- apiFetch('/api/admin/themes').catch(() => null),
- apiFetch('/api/admin/policy').catch(() => null),
- apiFetch('/api/admin/telemetry').catch(() => null),
- ]);
-
- if (statusRes.ok) setStatus(await statusRes.json());
- if (auditRes.ok) {
- const data = await auditRes.json();
- setRecentActivity(data.entries || []);
- }
- let configData: ConfigData | null = null;
- if (configRes.ok) {
- configData = await configRes.json();
- setConfig(configData);
- }
-
- if (pluginRes?.ok) {
- const plugins = await pluginRes.json();
- setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
- }
- if (themeRes?.ok) {
- const themes = await themeRes.json();
- setThemeCount(Array.isArray(themes) ? themes.length : 0);
- }
- if (policyRes?.ok) {
- const policy = await policyRes.json();
- const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
- const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
- setPolicyRuleCount(restrictionCount + disabledGates);
- }
- if (telemetryRes?.ok) {
- const telemetry = await telemetryRes.json();
- if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
- setAccountCounts(telemetry.accountCounts);
- }
- }
-
- 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; } })() : '-';
-
- return (
-
- {warnings.map((msg, i) => (
-
- ))}
-
-
-
- {config?.appName || '-'}
-
-
- {jmapHostname}
-
-
-
- {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
-
-
-
-
-
-
- {}} disabled />
-
-
- {}} disabled />
-
-
- {}} disabled />
-
-
- {}} disabled />
-
-
-
-
-
- {accountCounts?.total ?? '-'}
-
-
- {accountCounts?.active7d ?? '-'}
-
-
-
-
-
- {pluginCount}
-
-
- {themeCount}
-
-
- {policyRuleCount}
-
-
-
-
- {recentActivity.length === 0 ? (
-
- No activity recorded yet
-
- ) : (
- recentActivity.map((entry, i) => (
-
-
- {entry.ip}
- {new Date(entry.ts).toLocaleString()}
-
-
- ))
- )}
-
-
- );
-}
-
-function formatDetail(detail: Record): string {
- if (!detail || Object.keys(detail).length === 0) return '';
- if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
- if (detail.reason) return String(detail.reason);
- if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
- return JSON.stringify(detail).slice(0, 80);
-}
diff --git a/app/(main)/admin/_tabs/logs.tsx b/app/(main)/admin/_tabs/logs.tsx
deleted file mode 100644
index 181891e25..000000000
--- a/app/(main)/admin/_tabs/logs.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-'use client';
-
-import { useEffect, useState, useCallback } from 'react';
-import { RefreshCw } from '@/components/icons';
-import type { AuditEntry } from '@/lib/admin/types';
-import { apiFetch } from '@/lib/browser-navigation';
-
-export function LogsTab() {
- const [entries, setEntries] = useState([]);
- const [total, setTotal] = useState(0);
- const [page, setPage] = useState(1);
- const [loading, setLoading] = useState(true);
- const [actionFilter, setActionFilter] = useState('');
- const limit = 50;
-
- const fetchLogs = useCallback(async () => {
- setLoading(true);
- const params = new URLSearchParams({ page: String(page), limit: String(limit) });
- if (actionFilter) params.set('action', actionFilter);
-
- const res = await apiFetch(`/api/admin/audit?${params}`);
- if (res.ok) {
- const data = await res.json();
- setEntries(data.entries || []);
- setTotal(data.total || 0);
- }
- setLoading(false);
- }, [page, actionFilter]);
-
- useEffect(() => { fetchLogs(); }, [fetchLogs]);
-
- const totalPages = Math.max(1, Math.ceil(total / limit));
-
- return (
-
-
-
-
Audit Log
-
{total} total entries
-
-
-
- Refresh
-
-
-
-
- { setActionFilter(e.target.value); setPage(1); }}
- className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- >
- All actions
- Login
- Logout
- Login Failed
- Login Blocked
- Password Change
- Config Update
- Config Revert
- Policy Update
-
-
-
-
- {loading && entries.length === 0 ? (
-
Loading...
- ) : entries.length === 0 ? (
-
No entries found
- ) : (
- entries.map((entry, i) => (
-
-
-
- {entry.action}
-
-
- {new Date(entry.ts).toLocaleString()}
-
-
-
- {formatDetail(entry.detail)}
-
-
- {entry.ip}
-
-
- ))
- )}
-
-
-
-
-
-
- Time
- Action
- Details
- IP
-
-
-
- {loading && entries.length === 0 ? (
-
- Loading...
-
- ) : entries.length === 0 ? (
-
- No entries found
-
- ) : (
- entries.map((entry, i) => (
-
-
- {new Date(entry.ts).toLocaleString()}
-
-
-
- {entry.action}
-
-
-
- {formatDetail(entry.detail)}
-
-
- {entry.ip}
-
-
- ))
- )}
-
-
-
-
- {totalPages > 1 && (
-
-
- Page {page} of {totalPages}
-
-
- setPage(p => Math.max(1, p - 1))}
- disabled={page === 1}
- className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
- >
- Previous
-
- setPage(p => Math.min(totalPages, p + 1))}
- disabled={page === totalPages}
- className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
- >
- Next
-
-
-
- )}
-
- );
-}
-
-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/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx
deleted file mode 100644
index d5bc588ae..000000000
--- a/app/(main)/admin/_tabs/marketplace.tsx
+++ /dev/null
@@ -1,426 +0,0 @@
-'use client';
-
-import { useEffect, useState, useCallback } from 'react';
-import Link from 'next/link';
-import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from '@/components/icons';
-import { apiFetch } from '@/lib/browser-navigation';
-import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
-
-const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
-
-interface Extension {
- slug: string;
- name: string;
- type: 'plugin' | 'theme';
- pluginType: string | null;
- description: string;
- permissions: string[];
- tags: string[];
- totalDownloads: number;
- featured: boolean;
- minAppVersion: string | null;
- latestVersion: string | null;
- installed: boolean;
- installedVersion: string | null;
- iconUrl: string | null;
- bannerUrl: string | null;
- author: {
- displayName: string;
- githubLogin: string;
- avatarUrl: string | null;
- } | null;
-}
-
-interface SearchResult {
- data: Extension[];
- meta: {
- page: number;
- perPage: number;
- total: number;
- };
-}
-
-type TypeFilter = 'all' | 'plugin' | 'theme';
-
-export function MarketplaceTab() {
- const [extensions, setExtensions] = useState([]);
- const [loading, setLoading] = useState(true);
- const [query, setQuery] = useState('');
- const [typeFilter, setTypeFilter] = useState('all');
- const [page, setPage] = useState(1);
- const [total, setTotal] = useState(0);
- const [perPage] = useState(12);
- const [installing, setInstalling] = useState(null);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
- const [error, setError] = useState(null);
-
- const fetchExtensions = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- const params = new URLSearchParams();
- if (query) params.set('q', query);
- if (typeFilter !== 'all') params.set('type', typeFilter);
- params.set('page', String(page));
- params.set('perPage', String(perPage));
- params.set('sort', 'newest');
-
- const res = await apiFetch(`/api/admin/marketplace?${params}`);
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- setError(data.error || 'Failed to connect to extension directory');
- setExtensions([]);
- return;
- }
-
- const data: SearchResult = await res.json();
- setExtensions(data.data || []);
- setTotal(data.meta?.total || 0);
- } catch {
- setError('Failed to connect to extension directory. Make sure it is running.');
- setExtensions([]);
- } finally {
- setLoading(false);
- }
- }, [query, typeFilter, page, perPage]);
-
- useEffect(() => {
- fetchExtensions();
- }, [fetchExtensions]);
-
- const [searchInput, setSearchInput] = useState('');
- useEffect(() => {
- const t = setTimeout(() => {
- setQuery(searchInput);
- setPage(1);
- }, 300);
- return () => clearTimeout(t);
- }, [searchInput]);
-
- async function handleInstall(ext: Extension) {
- if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
- setMessage({
- type: 'error',
- text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
- });
- return;
- }
- const isUpdate = ext.installed;
- const targetVersion = ext.latestVersion || '1.0.0';
- setInstalling(ext.slug);
- setMessage(null);
-
- try {
- const res = await apiFetch('/api/admin/marketplace', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- slug: ext.slug,
- version: targetVersion,
- type: ext.type,
- }),
- });
-
- const data = await res.json();
-
- if (res.ok) {
- const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
- setMessage({
- type: 'success',
- text: isUpdate
- ? `"${ext.name}" updated to v${targetVersion}${warnings}`
- : `"${ext.name}" installed successfully${warnings}`,
- });
- setExtensions(prev => prev.map(e =>
- e.slug === ext.slug
- ? { ...e, installed: true, installedVersion: targetVersion }
- : e,
- ));
- } else {
- setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
- }
- } catch {
- setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
- } finally {
- setInstalling(null);
- }
- }
-
- const totalPages = Math.ceil(total / perPage);
-
- return (
-
-
-
Marketplace
-
- Browse and install plugins and themes from the BulwarkMail extension directory
-
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
- setSearchInput(e.target.value)}
- className="w-full h-9 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"
- />
-
-
- {(['all', 'plugin', 'theme'] as const).map((t) => (
- { setTypeFilter(t); setPage(1); }}
- className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
- typeFilter === t
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground hover:text-foreground'
- }`}
- >
- {t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
-
- ))}
-
-
-
- {error && (
-
-
-
{error}
-
- Start the extension directory server on the configured port
-
-
- Retry
-
-
- )}
-
- {loading && !error && (
-
-
- Searching extensions...
-
- )}
-
- {!loading && !error && extensions.length === 0 && (
-
-
-
No extensions found
- {query && (
-
- Try a different search term
-
- )}
-
- )}
-
- {!loading && !error && extensions.length > 0 && (
- <>
-
- {total} extension{total !== 1 ? 's' : ''} found
-
-
- {extensions.map((ext) => (
- handleInstall(ext)}
- />
- ))}
-
-
- {totalPages > 1 && (
-
- setPage(p => Math.max(1, p - 1))}
- disabled={page <= 1}
- className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Previous
-
-
- Page {page} of {totalPages}
-
- setPage(p => Math.min(totalPages, p + 1))}
- disabled={page >= totalPages}
- className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Next
-
-
- )}
- >
- )}
-
- );
-}
-
-function ExtensionCard({
- extension,
- installing,
- onInstall,
-}: {
- extension: Extension;
- installing: boolean;
- onInstall: () => void;
-}) {
- const isPlugin = extension.type === 'plugin';
- const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
- const versionMismatch = !!extension.minAppVersion
- && !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
- const updateAvailable = extension.installed
- && !!extension.installedVersion
- && !!extension.latestVersion
- && compareVersions(extension.latestVersion, extension.installedVersion) > 0
- && !versionMismatch;
-
- return (
-
- {extension.bannerUrl && (
-
-
-
- )}
-
-
-
- {extension.iconUrl ? (
-
- ) : isPlugin ? (
-
- ) : (
-
- )}
-
-
-
-
- {extension.name}
-
- {extension.featured && (
-
- )}
-
-
-
- {isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
-
- {extension.author && (
-
- by {extension.author.displayName}
-
- )}
-
-
-
-
-
- {extension.description}
-
-
- {extension.tags && extension.tags.length > 0 && (
-
- {extension.tags.slice(0, 3).map(tag => (
-
- {tag}
-
- ))}
-
- )}
-
-
-
-
-
- {extension.totalDownloads.toLocaleString()}
-
- {extension.permissions && extension.permissions.length > 0 && (
-
- {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
-
- )}
-
-
-
- Preview
-
-
-
-
-
- {extension.installed && updateAvailable ? (
-
{ e.preventDefault(); e.stopPropagation(); onInstall(); }}
- disabled={installing}
- title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
- className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
- >
- {installing ? (
-
- ) : (
-
- )}
- Update to v{extension.latestVersion}
-
- ) : extension.installed ? (
-
-
- Installed
-
- ) : versionMismatch ? (
-
-
- Requires v{extension.minAppVersion}+
-
- ) : (
-
{ e.preventDefault(); e.stopPropagation(); onInstall(); }}
- disabled={installing}
- className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
- >
- {installing ? (
-
- ) : (
-
- )}
- Quick install
-
- )}
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx
deleted file mode 100644
index 51fe96758..000000000
--- a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useState } from 'react';
-import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons';
-import { apiFetch } from '@/lib/browser-navigation';
-import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
-
-interface ApprovalEntry {
- pluginId: string;
- bundleHash: string;
- status: 'pending' | 'approved' | 'denied';
- manifest: {
- name?: string;
- version?: string;
- author?: string;
- description?: string;
- permissions?: string[];
- httpOrigins?: string[];
- apiPostPaths?: string[];
- };
- requestedBy: string;
- requestedAt: string;
- decidedAt?: string;
-}
-
-interface SourceView {
- entry: ApprovalEntry;
- code?: string;
- warnings?: string[];
- error?: string;
-}
-
-const STATUS_STYLE: Record = {
- pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400',
- approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400',
- denied: 'bg-muted text-muted-foreground',
-};
-
-/**
- * Plugins users installed themselves and asked to have approved. The admin
- * decides on the package the user uploaded: its manifest and code are read
- * on the server, and the approval is pinned to that exact package.
- */
-export function PluginApprovalsPanel() {
- const [entries, setEntries] = useState(null);
- const [error, setError] = useState(null);
- const [busy, setBusy] = useState(null);
- const [source, setSource] = useState(null);
-
- const load = useCallback(async () => {
- try {
- const res = await apiFetch('/api/admin/plugin-approvals');
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json() as { entries: ApprovalEntry[] };
- setEntries([...data.entries].sort((a, b) =>
- Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt)));
- setError(null);
- } catch (e) {
- setError(`Could not load approval requests (${(e as Error).message})`);
- }
- }, []);
-
- useEffect(() => { void load(); }, [load]);
-
- async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') {
- const key = `${entry.pluginId}:${entry.bundleHash}`;
- setBusy(key);
- try {
- const res = decision === 'revoke'
- ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' })
- : await apiFetch('/api/admin/plugin-approvals', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }),
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- await load();
- } catch (e) {
- setError(`Could not save the decision (${(e as Error).message})`);
- } finally {
- setBusy(null);
- }
- }
-
- async function viewSource(entry: ApprovalEntry) {
- setSource({ entry });
- try {
- const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`);
- const data = await res.json();
- if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
- setSource({ entry, code: data.code, warnings: data.warnings });
- } catch (e) {
- setSource({ entry, error: (e as Error).message });
- }
- }
-
- const pending = entries?.filter((e) => e.status === 'pending').length ?? 0;
-
- return (
-
-
-
-
-
Approval Requests
- {pending > 0 && (
- {pending} waiting
- )}
-
-
Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.
-
-
- {error &&
{error}
}
-
- {entries === null ? (
-
- ) : entries.length === 0 ? (
-
No requests yet
- ) : (
-
- {entries.map((entry) => {
- const key = `${entry.pluginId}:${entry.bundleHash}`;
- const perms = entry.manifest.permissions ?? [];
- return (
-
-
- {entry.manifest.name ?? entry.pluginId}
- v{entry.manifest.version}
- {entry.status}
-
- {entry.manifest.description &&
{entry.manifest.description}
}
-
- {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
-
- {perms.length > 0 && (
-
- {perms.map((p) => (
- {p}
- ))}
-
- )}
- {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && (
-
- Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')}
-
- )}
-
- viewSource(entry)} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs hover:bg-accent">
- View code
-
- {entry.status !== 'approved' && (
- decide(entry, 'approved')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 disabled:opacity-50">
- Approve
-
- )}
- {entry.status !== 'denied' && (
- decide(entry, 'denied')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-foreground text-xs font-medium hover:bg-accent disabled:opacity-50">
- Deny
-
- )}
- {entry.status !== 'pending' && (
- decide(entry, 'revoke')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md text-xs text-muted-foreground hover:text-destructive disabled:opacity-50">
- Forget decision
-
- )}
-
-
- );
- })}
-
- )}
-
- {source && (
-
{ if (e.target === e.currentTarget) setSource(null); }}>
-
-
- {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}
- setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent">
-
- {source.warnings && source.warnings.length > 0 && (
-
-
{source.warnings.join('; ')}
-
- )}
-
- {source.error ?? source.code ?? 'Loading…'}
-
-
-
- )}
-
- );
-}
diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx
deleted file mode 100644
index d396c086a..000000000
--- a/app/(main)/admin/_tabs/plugin-config-panel.tsx
+++ /dev/null
@@ -1,314 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons';
-import { apiFetch } from '@/lib/browser-navigation';
-import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
-import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
-
-interface ConfigField {
- type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
- label: string;
- description?: string;
- required?: boolean;
- default?: unknown;
- placeholder?: string;
- options?: { label: string; value: string }[];
-}
-
-interface PluginConfig {
- [key: string]: unknown;
-}
-
-interface PluginInfo {
- id: string;
- name: string;
- description: string;
- version: string;
- author: string;
- type: string;
- permissions: string[];
- enabled: boolean;
- configSchema?: Record;
-}
-
-interface Props {
- pluginId: string;
- onBack: () => void;
-}
-
-export function PluginConfigPanel({ pluginId, onBack }: Props) {
- const [plugin, setPlugin] = useState(null);
- const [config, setConfig] = useState({});
- const [formValues, setFormValues] = useState>({});
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [revealSecrets, setRevealSecrets] = useState>({});
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
-
- useEffect(() => {
- let cancelled = false;
- async function fetchData() {
- setLoading(true);
- try {
- const [pluginsRes, configRes] = await Promise.all([
- apiFetch('/api/admin/plugins'),
- apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
- ]);
- if (cancelled) return;
-
- if (pluginsRes.ok) {
- const plugins: PluginInfo[] = await pluginsRes.json();
- setPlugin(plugins.find(p => p.id === pluginId) || null);
- }
-
- if (configRes.ok) {
- setConfig(await configRes.json());
- }
- } finally {
- if (!cancelled) setLoading(false);
- }
- }
- fetchData();
- return () => { cancelled = true; };
- }, [pluginId]);
-
- useEffect(() => {
- if (!plugin?.configSchema) return;
- const initial: Record = {};
- for (const [key, field] of Object.entries(plugin.configSchema)) {
- 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] = '';
- }
- }
- setFormValues(initial);
- }, [plugin, config]);
-
- async function handleSaveAll() {
- if (!plugin?.configSchema) return;
- setSaving(true);
- setMessage(null);
-
- for (const [key, field] of Object.entries(plugin.configSchema)) {
- if (field.required && !formValues[key]?.trim()) {
- setMessage({ type: 'error', text: `"${field.label}" is required` });
- setSaving(false);
- return;
- }
- }
-
- try {
- let hasError = false;
- for (const [key, field] of Object.entries(plugin.configSchema)) {
- const newVal = formValues[key] ?? '';
- const oldVal = config[key] !== undefined ? String(config[key]) : '';
-
- if (newVal === oldVal) continue;
- if (field.type === 'secret' && !newVal && config[key]) continue;
-
- let value: unknown = newVal;
- if (field.type === 'boolean') value = newVal === 'true';
- else if (field.type === 'number') value = Number(newVal);
-
- if (!newVal && !field.required) {
- const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
- 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 {
- hasError = true;
- }
- continue;
- }
-
- const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key, value }),
- });
- if (res.ok) {
- setConfig(prev => ({ ...prev, [key]: value }));
- } else {
- hasError = true;
- }
- }
-
- setMessage(hasError
- ? { type: 'error', text: 'Some settings failed to save' }
- : { type: 'success', text: 'Configuration saved' }
- );
- } catch {
- setMessage({ type: 'error', text: 'Failed to save configuration' });
- } finally {
- setSaving(false);
- }
- }
-
- if (loading) {
- return (
-
-
- Loading...
-
- );
- }
-
- if (!plugin) {
- return (
-
-
- Back to Plugins
-
-
Plugin not found: {pluginId}
-
- );
- }
-
- const schema = plugin.configSchema;
- const hasSchema = schema && Object.keys(schema).length > 0;
-
- return (
-
-
-
-
-
-
-
-
- {plugin.name} Configuration
-
-
- v{plugin.version} by {plugin.author}
-
-
-
-
- {message && (
-
- {message.text}
-
- )}
-
- {hasSchema ? (
-
-
-
Settings
-
-
- {Object.entries(schema).map(([key, field]) => (
-
-
- {field.label}
- {field.required && * }
-
- {field.description && (
-
{field.description}
- )}
-
- {field.type === 'boolean' ? (
-
setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
- className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
- >
- Enabled
- Disabled
-
- ) : field.type === 'select' && field.options ? (
-
setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
- className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
- >
- - Select -
- {field.options.map(opt => (
- {opt.label}
- ))}
-
- ) : field.type === 'secret' ? (
-
- setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
- placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
- className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
- />
- setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
- className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
- aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
- >
- {revealSecrets[key] ? : }
-
-
- ) : (
-
setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
- placeholder={field.placeholder || ''}
- className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
- />
- )}
-
- ))}
-
-
- {saving ? : null}
- Save Configuration
-
-
-
- ) : (
-
-
This plugin does not declare any configuration settings.
-
- )}
-
-
-
- );
-}
-
-/**
- * Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
- * one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
- * and the host gives it a dedicated iframe inside the admin panel.
- */
-function PluginAdminSection({ pluginId }: { pluginId: string }) {
- const offers = usePluginSlotOffers('admin-plugin-page');
- const offer = offers.find((o) => o.pluginId === pluginId);
- if (!offer) return null;
- return (
-
-
- Plugin admin panel
-
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx
deleted file mode 100644
index 908820b7e..000000000
--- a/app/(main)/admin/_tabs/plugins.tsx
+++ /dev/null
@@ -1,465 +0,0 @@
-'use client';
-
-import { useEffect, useState, useRef } from 'react';
-import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from '@/components/icons';
-import type { SettingsPolicy } from '@/lib/admin/types';
-import { DEFAULT_POLICY } from '@/lib/admin/types';
-import { apiFetch } from '@/lib/browser-navigation';
-import { PluginConfigPanel } from './plugin-config-panel';
-import { PluginApprovalsPanel } from './plugin-approvals-panel';
-
-interface PluginEntry {
- id: string;
- name: string;
- version: string;
- author: string;
- description: string;
- type: string;
- enabled: boolean;
- forceEnabled?: boolean;
- permissions: string[];
- installedAt: string;
- updatedAt: string;
-}
-
-export function PluginsTab() {
- const [plugins, setPlugins] = useState([]);
- const [loading, setLoading] = useState(true);
- const [uploading, setUploading] = useState(false);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
- const fileInputRef = useRef(null);
- const [policy, setPolicy] = useState({ ...DEFAULT_POLICY });
- const [policyDirty, setPolicyDirty] = useState(false);
- const [savingPolicy, setSavingPolicy] = useState(false);
- const [configuringId, setConfiguringId] = useState(null);
-
- useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
-
- async function fetchPolicy() {
- try {
- const res = await apiFetch('/api/admin/policy');
- if (res.ok) {
- const data = await res.json();
- setPolicy(data);
- }
- } catch { /* ignore */ }
- }
-
- function togglePluginsEnabled() {
- setPolicy(prev => ({
- ...prev,
- features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
- }));
- setPolicyDirty(true);
- setMessage(null);
- }
-
- function togglePluginsUploadEnabled() {
- setPolicy(prev => ({
- ...prev,
- features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
- }));
- setPolicyDirty(true);
- setMessage(null);
- }
-
- function toggleRequirePluginApproval() {
- setPolicy(prev => ({
- ...prev,
- features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
- }));
- setPolicyDirty(true);
- setMessage(null);
- }
-
- async function handleSavePolicy() {
- setSavingPolicy(true);
- setMessage(null);
- try {
- const res = await apiFetch('/api/admin/policy', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(policy),
- });
- if (res.ok) {
- setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
- setPolicyDirty(false);
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
- }
- } catch {
- setMessage({ type: 'error', text: 'Failed to save policy' });
- } finally {
- setSavingPolicy(false);
- }
- }
-
- async function fetchPlugins() {
- setLoading(true);
- try {
- const res = await apiFetch('/api/admin/plugins');
- if (res.ok) setPlugins(await res.json());
- } finally {
- setLoading(false);
- }
- }
-
- async function handleUpload(e: React.ChangeEvent) {
- const file = e.target.files?.[0];
- if (!file) return;
-
- setUploading(true);
- setMessage(null);
-
- const formData = new FormData();
- formData.append('file', file);
-
- try {
- const res = await apiFetch('/api/admin/plugins', {
- method: 'POST',
- body: formData,
- });
-
- const data = await res.json();
- if (res.ok) {
- const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
- setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
- await fetchPlugins();
- } else {
- setMessage({ type: 'error', text: data.error || 'Upload failed' });
- }
- } catch {
- setMessage({ type: 'error', text: 'Upload failed' });
- } finally {
- setUploading(false);
- if (fileInputRef.current) fileInputRef.current.value = '';
- }
- }
-
- async function togglePlugin(id: string, enabled: boolean) {
- setMessage(null);
- const res = await apiFetch('/api/admin/plugins', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id, enabled }),
- });
-
- if (res.ok) {
- setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Update failed' });
- }
- }
-
- async function toggleForceEnabled(id: string, forceEnabled: boolean) {
- setMessage(null);
- const body: Record = { id, forceEnabled };
- if (forceEnabled) body.enabled = true;
-
- const res = await apiFetch('/api/admin/plugins', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
-
- if (res.ok) {
- setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
- setPolicy(prev => {
- const current = prev.forceEnabledPlugins || [];
- return {
- ...prev,
- forceEnabledPlugins: forceEnabled
- ? [...current.filter(pid => pid !== id), id]
- : current.filter(pid => pid !== id),
- };
- });
- setPolicyDirty(true);
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Update failed' });
- }
- }
-
- async function forceEnableAll() {
- setMessage(null);
- const disabled = plugins.filter(p => !p.enabled);
- if (disabled.length === 0) {
- setMessage({ type: 'success', text: 'All plugins are already enabled' });
- return;
- }
- let failed = 0;
- for (const p of disabled) {
- const res = await apiFetch('/api/admin/plugins', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: p.id, enabled: true }),
- });
- if (!res.ok) failed++;
- }
- setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
- if (failed === 0) {
- await fetchPlugins();
- setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
- } else {
- await fetchPlugins();
- setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
- }
- }
-
- async function forceDisableAll() {
- setMessage(null);
- const enabled = plugins.filter(p => p.enabled);
- if (enabled.length === 0) {
- setMessage({ type: 'success', text: 'All plugins are already disabled' });
- return;
- }
- let failed = 0;
- for (const p of enabled) {
- const res = await apiFetch('/api/admin/plugins', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: p.id, enabled: false }),
- });
- if (!res.ok) failed++;
- }
- if (failed === 0) {
- await fetchPlugins();
- setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
- } else {
- await fetchPlugins();
- setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
- }
- }
-
- async function deletePlugin(id: string, name: string) {
- if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
-
- setMessage(null);
- const res = await apiFetch('/api/admin/plugins', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id }),
- });
-
- if (res.ok) {
- setPlugins(prev => prev.filter(p => p.id !== id));
- setMessage({ type: 'success', text: `Plugin "${name}" removed` });
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Delete failed' });
- }
- }
-
- if (configuringId) {
- return { setConfiguringId(null); fetchPlugins(); }} />;
- }
-
- if (loading) {
- return Loading...
;
- }
-
- const pluginsEnabled = policy.features.pluginsEnabled ?? true;
- const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
- const requirePluginApproval = policy.features.requirePluginApproval ?? true;
-
- return (
-
-
-
-
Plugins
-
Manage plugins and plugin policy for all users
-
-
- {policyDirty && (
-
- {savingPolicy ? : }
- Save Policy
-
- )}
-
- {uploading ? : }
- Upload Plugin
-
-
-
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
-
-
Plugin Policy
-
-
Control plugin availability for users
-
-
-
-
-
Plugins Enabled
-
Allow the plugin system to load and run plugins for users
-
-
-
-
-
-
-
-
-
User Plugin Uploads
-
Allow users to upload plugin ZIP files in Settings
-
-
-
-
-
-
-
-
-
Require Admin Approval
-
User-uploaded plugins must be approved by an admin before they can be enabled
-
-
-
-
-
-
- {plugins.length > 0 && (
-
-
-
Force Enable / Disable All
-
Bulk toggle all deployed plugins at once
-
-
-
-
- Enable All
-
-
-
- Disable All
-
-
-
- )}
-
-
-
- {pluginsUploadEnabled &&
}
-
-
-
-
-
Admin-uploaded plugins for all users
-
- {plugins.length === 0 ? (
-
-
-
No plugins installed
-
Upload a plugin ZIP file to get started
-
- ) : (
-
- {plugins.map(plugin => (
-
-
-
- {plugin.name}
- v{plugin.version}
-
- {plugin.enabled ? 'Enabled' : 'Disabled'}
-
- {plugin.forceEnabled && (
-
- Forced
-
- )}
-
- {plugin.description && (
-
{plugin.description}
- )}
-
- by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
-
- {plugin.permissions.length > 0 && (
-
-
-
- Permissions: {plugin.permissions.join(', ')}
-
-
- )}
-
-
-
-
setConfiguringId(plugin.id)}
- title="Configure"
- className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
- >
-
-
-
toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
- title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
- className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
- >
- {plugin.forceEnabled ? : }
-
-
togglePlugin(plugin.id, !plugin.enabled)}
- title={plugin.enabled ? 'Disable' : 'Enable'}
- className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
- >
-
-
-
deletePlugin(plugin.id, plugin.name)}
- title="Remove"
- className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
- >
-
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx
deleted file mode 100644
index e42478bac..000000000
--- a/app/(main)/admin/_tabs/policy.tsx
+++ /dev/null
@@ -1,620 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { Save, Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
-import { iconForName } from '@/components/icons';
-import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
-import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
-import { apiFetch } from '@/lib/browser-navigation';
-import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
-import { generateUUID } from '@/lib/utils';
-import {
- DEFAULT_RELAY_BASE_URL,
- isValidRelayUrl,
- normalizeRelayUrl,
- resolveDefaultRelayUrl,
-} from '@/lib/push-relays';
-
-// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
-// forward on policy load), so it is hidden from the admin UI.
-const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
-
-const FEATURE_GATE_LABELS: Partial> = {
- sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
- settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
- customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
- templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
- calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
- calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
- contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
- smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
- externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
- debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
- folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
- hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
- tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
- filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
- crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
- crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
- crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
- unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
- accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. No mailbox passwords are stored. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
- customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' },
-};
-
-const RESTRICTABLE_SETTINGS = [
- { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
- { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
- { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
- { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
- { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
- { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
- { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
- { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
- { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
- { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
- { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
- { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
- { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
- { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] },
- { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
- { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
- { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
- { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
- { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' },
- { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
-];
-
-/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */
-function isValidDefaultAppUrl(raw: string): boolean {
- try {
- const parsed = new URL(raw.trim());
- return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host;
- } catch {
- return false;
- }
-}
-
-export function PolicyTab() {
- const [policy, setPolicy] = useState({ ...DEFAULT_POLICY });
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
- const [dirty, setDirty] = useState(false);
-
- useEffect(() => { fetchPolicy(); }, []);
-
- async function fetchPolicy() {
- setLoading(true);
- try {
- const res = await apiFetch('/api/admin/policy');
- if (res.ok) {
- const data = await res.json();
- setPolicy(data);
- }
- } finally {
- setLoading(false);
- }
- }
-
- function toggleFeature(key: keyof FeatureGates) {
- setPolicy(prev => ({
- ...prev,
- features: { ...prev.features, [key]: !prev.features[key] },
- }));
- setDirty(true);
- setMessage(null);
- }
-
- function setPushRelayUrl(value: string) {
- setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
- setDirty(true);
- setMessage(null);
- }
-
- function updatePushRelay(index: number, patch: Partial) {
- setPolicy(prev => ({
- ...prev,
- pushRelays: (prev.pushRelays ?? []).map((relay, i) => (i === index ? { ...relay, ...patch } : relay)),
- }));
- setDirty(true);
- setMessage(null);
- }
-
- function addPushRelay() {
- setPolicy(prev => ({ ...prev, pushRelays: [...(prev.pushRelays ?? []), { label: '', url: '' }] }));
- setDirty(true);
- setMessage(null);
- }
-
- function removePushRelay(index: number) {
- setPolicy(prev => {
- const remaining = (prev.pushRelays ?? []).filter((_, i) => i !== index);
- const removedUrl = normalizeRelayUrl((prev.pushRelays ?? [])[index]?.url);
- // Dropping the relay users are defaulted to would leave a dangling
- // default, so fall back to the built-in one.
- const pushRelayUrl = normalizeRelayUrl(prev.pushRelayUrl) === removedUrl ? '' : prev.pushRelayUrl;
- return { ...prev, pushRelays: remaining, pushRelayUrl };
- });
- setDirty(true);
- setMessage(null);
- }
-
- function togglePushRelayLocked() {
- setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
- setDirty(true);
- setMessage(null);
- }
-
- function updateDefaultApp(index: number, patch: Partial) {
- setPolicy(prev => ({
- ...prev,
- defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)),
- }));
- setDirty(true);
- setMessage(null);
- }
-
- function addDefaultApp() {
- setPolicy(prev => {
- const apps = prev.defaultSidebarApps ?? [];
- if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev;
- return {
- ...prev,
- defaultSidebarApps: [
- ...apps,
- {
- id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`,
- name: '',
- url: '',
- icon: 'Globe',
- openMode: 'tab' as const,
- showOnMobile: false,
- },
- ],
- };
- });
- setDirty(true);
- setMessage(null);
- }
-
- function removeDefaultApp(index: number) {
- setPolicy(prev => ({
- ...prev,
- defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index),
- }));
- setDirty(true);
- setMessage(null);
- }
-
- /** Rail order follows this list, so admins need to be able to reorder it. */
- function moveDefaultApp(index: number, delta: number) {
- setPolicy(prev => {
- const apps = [...(prev.defaultSidebarApps ?? [])];
- const target = index + delta;
- if (target < 0 || target >= apps.length) return prev;
- [apps[index], apps[target]] = [apps[target], apps[index]];
- return { ...prev, defaultSidebarApps: apps };
- });
- setDirty(true);
- setMessage(null);
- }
-
- function toggleLocked(settingKey: string) {
- setPolicy(prev => {
- const existing = prev.restrictions[settingKey] || {};
- const newRestrictions = { ...prev.restrictions };
- if (existing.locked) {
- delete newRestrictions[settingKey];
- } else {
- newRestrictions[settingKey] = { ...existing, locked: true };
- }
- return { ...prev, restrictions: newRestrictions };
- });
- setDirty(true);
- setMessage(null);
- }
-
- function toggleHidden(settingKey: string) {
- setPolicy(prev => {
- const existing = prev.restrictions[settingKey] || {};
- const newRestrictions = { ...prev.restrictions };
- newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
- if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
- delete newRestrictions[settingKey];
- }
- return { ...prev, restrictions: newRestrictions };
- });
- setDirty(true);
- setMessage(null);
- }
-
- async function handleSave() {
- setSaving(true);
- setMessage(null);
-
- const res = await apiFetch('/api/admin/policy', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(policy),
- });
-
- if (res.ok) {
- setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
- setDirty(false);
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Failed to save' });
- }
- setSaving(false);
- }
-
- if (loading) {
- return Loading...
;
- }
-
- const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
- const defaultRelayUrl = resolveDefaultRelayUrl(policy);
- const defaultSidebarApps = policy.defaultSidebarApps ?? [];
-
- return (
-
-
-
-
User Policy
-
Control which features and settings users can access
-
- {dirty && (
-
- {saving ? : }
- Save policy
-
- )}
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
Feature Gates
-
Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.
-
-
- {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
- .filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
- .map(key => {
- const meta = FEATURE_GATE_LABELS[key];
- if (!meta) return null;
- const { label, description } = meta;
- const enabled = policy.features[key];
- return (
-
-
-
{label}
-
{description}
-
-
toggleFeature(key)}
- className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
-
-
-
- );
- })}
-
-
-
-
-
-
Push Relays
-
- Relays users can pick from in notification settings. The built-in Bulwark relay is always
- offered; add your own here. Users choose from this list - they cannot enter a URL.
-
-
-
-
-
-
Relays
-
- Mark one as the default. Relays without a valid URL are not offered to users.
-
-
-
-
- Add relay
-
-
-
-
-
-
-
- Bulwark relay
-
- built-in
-
-
-
{DEFAULT_RELAY_BASE_URL}
-
-
- setPushRelayUrl('')}
- className="border-input"
- />
- Default
-
-
-
-
- {(policy.pushRelays ?? []).map((relay, index) => (
-
-
-
- Name
- updatePushRelay(index, { label: e.target.value })}
- placeholder="Company relay"
- 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"
- />
-
-
-
Relay URL
-
- updatePushRelay(index, { url: e.target.value })}
- placeholder="https://notifications.relay.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"
- />
- removePushRelay(index)}
- className="shrink-0 text-muted-foreground hover:text-destructive"
- title="Remove relay"
- >
-
-
-
-
-
-
- setPushRelayUrl(normalizeRelayUrl(relay.url))}
- className="border-input disabled:opacity-40"
- />
- Default
-
-
- ))}
-
-
-
- Lock - users are pinned to the default relay
-
-
-
-
-
-
-
Default Sidebar Apps
-
- Apps every user gets in the navigation rail without configuring anything. They appear
- above the user's own apps and cannot be edited or removed by users. Independent of
- the Sidebar Apps feature gate above, which only governs user-added apps.
-
-
-
-
-
-
Apps
-
- Entries without a name or a valid http(s) URL are dropped when saving. Inline apps
- are framed inside the webmail, so the target must allow being embedded.
-
-
-
= MAX_DEFAULT_SIDEBAR_APPS}
- className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-50 disabled:pointer-events-none"
- >
-
- Add app
-
-
-
- {defaultSidebarApps.length === 0 && (
-
- No default apps. Users only see the apps they add themselves.
-
- )}
-
- {defaultSidebarApps.map((app, index) => {
- const AppIcon = iconForName(app.icon);
- const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url);
- return (
-
-
-
- Name
- updateDefaultApp(index, { name: e.target.value })}
- placeholder="Intranet"
- 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"
- />
-
-
-
URL
-
updateDefaultApp(index, { url: e.target.value })}
- placeholder="https://intranet.example.com"
- 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 ${urlInvalid ? 'border-destructive' : 'border-input'}`}
- />
- {urlInvalid && (
-
Enter a valid http or https URL
- )}
-
-
-
- Icon (
- Tabler
- )
-
-
-
updateDefaultApp(index, { icon: e.target.value })}
- placeholder="world"
- 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"
- />
-
- {AppIcon ? : ? }
-
-
-
-
-
-
-
-
- updateDefaultApp(index, { openMode: 'tab' })}
- className="border-input"
- />
- New tab
-
-
- updateDefaultApp(index, { openMode: 'inline' })}
- className="border-input"
- />
- Inline
-
-
-
- updateDefaultApp(index, { showOnMobile: !app.showOnMobile })}
- className="rounded border-input"
- />
- Show on mobile
-
-
-
moveDefaultApp(index, -1)}
- disabled={index === 0}
- className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:pointer-events-none p-1"
- title="Move up"
- >
-
-
-
moveDefaultApp(index, 1)}
- disabled={index === defaultSidebarApps.length - 1}
- className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:pointer-events-none p-1"
- title="Move down"
- >
-
-
-
removeDefaultApp(index)}
- className="text-muted-foreground hover:text-destructive p-1"
- title="Remove app"
- >
-
-
-
-
-
- );
- })}
-
-
-
- {categories.map(category => (
-
-
-
{category}
-
-
- {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
- const restriction = policy.restrictions[setting.key] || {};
- return (
-
- );
- })}
-
-
- ))}
-
- );
-}
diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx
deleted file mode 100644
index 9870d4ffa..000000000
--- a/app/(main)/admin/_tabs/settings.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { Save, RotateCcw, Loader2 } from '@/components/icons';
-import { apiFetch } from '@/lib/browser-navigation';
-import { JmapServersSection } from './_jmap-servers-section';
-import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
-
-interface ConfigEntry {
- value?: unknown;
- source: 'admin' | 'env' | 'default';
- hasValue?: boolean;
-}
-
-export function SettingsTab() {
- const [config, setConfig] = useState>({});
- const [edits, setEdits] = useState>({});
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
-
- useEffect(() => {
- fetchConfig();
- }, []);
-
- async function fetchConfig() {
- setLoading(true);
- const res = await apiFetch('/api/admin/config');
- if (res.ok) {
- setConfig(await res.json());
- }
- setLoading(false);
- }
-
- function handleChange(key: string, value: unknown) {
- setEdits(prev => ({ ...prev, [key]: value }));
- setMessage(null);
- }
-
- function currentValue(key: string): unknown {
- if (key in edits) return edits[key];
- return config[key]?.value;
- }
-
- async function handleSave() {
- if (Object.keys(edits).length === 0) return;
- setSaving(true);
- setMessage(null);
-
- const res = await apiFetch('/api/admin/config', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(edits),
- });
-
- if (res.ok) {
- setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
- setEdits({});
- await fetchConfig();
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Failed to save' });
- }
- setSaving(false);
- }
-
- async function handleRevert(key: string) {
- const res = await apiFetch('/api/admin/config', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key }),
- });
- if (res.ok) {
- setEdits(prev => {
- const next = { ...prev };
- delete next[key];
- return next;
- });
- await fetchConfig();
- setMessage({ type: 'success', text: `${key} reverted to default` });
- }
- }
-
- const hasEdits = Object.keys(edits).length > 0;
-
- if (loading) {
- return Loading...
;
- }
-
- return (
-
-
-
-
Server Settings
-
General server configuration
-
- {hasEdits && (
-
- {saving ? : }
- Save changes
-
- )}
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
-
- {!!currentValue('allowCustomJmapEndpoint') && (
-
-
- CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked.
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- handleChange('jmapServers', next)}
- onRevert={() => handleRevert('jmapServers')}
- />
- {Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
-
-
- CORS warning: Each JMAP server must allow this webmail's origin in its Access-Control-Allow-Origin header, or browser requests will be blocked.
-
-
- )}
-
-
-
-
-
-
-
- Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail's base path is added to it. The editor must also allow this webmail as a WOPI host.
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
- return (
-
-
-
{title}
-
-
- {children}
-
-
- );
-}
-
-function SourceBadge({ source }: { source?: string }) {
- if (!source || source === 'default') return null;
- return (
-
- {source}
-
- );
-}
-
-function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
- label: string; configKey: string; value: string; source?: string;
- onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
-}) {
- return (
-
-
- {label}
-
-
-
- onChange(configKey, e.target.value)}
- placeholder={placeholder}
- className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- />
- {source === 'admin' && (
- onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
-
-
- )}
-
-
- );
-}
-
-function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
- label: string; description?: string; configKey: string; value: boolean; source?: string;
- onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
-}) {
- return (
-
-
-
- {label}
-
-
- {description &&
{description}
}
-
-
- onChange(configKey, !value)}
- className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
- >
-
-
- {source === 'admin' && (
- onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
-
-
- )}
-
-
- );
-}
-
-function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
- label: string; configKey: string; value: string; source?: string; options: string[];
- onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
-}) {
- return (
-
-
- {label}
-
-
-
- onChange(configKey, e.target.value)}
- className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
- >
- {options.map(opt => {opt} )}
-
- {source === 'admin' && (
- onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
-
-
- )}
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/telemetry.tsx b/app/(main)/admin/_tabs/telemetry.tsx
deleted file mode 100644
index 22de16c8c..000000000
--- a/app/(main)/admin/_tabs/telemetry.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from '@/components/icons';
-import { apiFetch } from '@/lib/browser-navigation';
-
-interface TelemetryStatus {
- consent: 'pending' | 'on' | 'off';
- consentSource: 'env' | 'file';
- endpoint: string;
- defaultEndpoint: string;
- consentedAt: string | null;
- lastSentAt: string | null;
- nextScheduledAt: string | null;
- payloadPreview: Record;
- accountCounts: { total: number; active7d: number };
-}
-
-function timeAgo(iso: string | null): string {
- if (!iso) return 'never';
- const d = Date.now() - new Date(iso).getTime();
- if (d < 0) return new Date(iso).toLocaleString();
- const m = Math.floor(d / 60000);
- if (m < 1) return 'just now';
- if (m < 60) return `${m} min ago`;
- const h = Math.floor(m / 60);
- if (h < 48) return `${h} hours ago`;
- const days = Math.floor(h / 24);
- return `${days} days ago`;
-}
-
-export function TelemetryTab() {
- const [status, setStatus] = useState(null);
- const [loading, setLoading] = useState(true);
- const [busy, setBusy] = useState(null);
- const [endpointDraft, setEndpointDraft] = useState('');
- const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
-
- async function refresh(): Promise {
- setLoading(true);
- try {
- const r = await apiFetch('/api/admin/telemetry');
- if (!r.ok) throw new Error('failed to load');
- const data = (await r.json()) as TelemetryStatus;
- setStatus(data);
- setEndpointDraft(data.endpoint);
- } catch (err) {
- console.error(err);
- } finally {
- setLoading(false);
- }
- }
- useEffect(() => { void refresh(); }, []);
-
- async function setConsent(consent: 'on' | 'off'): Promise {
- setBusy('consent');
- try {
- const r = await apiFetch('/api/admin/telemetry', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ action: 'set-consent', consent }),
- });
- if (!r.ok) {
- const j = (await r.json().catch(() => ({}))) as { error?: string };
- alert(j.error ?? 'failed');
- }
- await refresh();
- } finally { setBusy(null); }
- }
-
- async function saveEndpoint(): Promise {
- setBusy('endpoint');
- try {
- const r = await apiFetch('/api/admin/telemetry', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
- });
- if (!r.ok) {
- const j = (await r.json().catch(() => ({}))) as { error?: string };
- alert(j.error ?? 'failed');
- }
- await refresh();
- } finally { setBusy(null); }
- }
-
- async function sendNow(): Promise {
- setBusy('send');
- setSendResult(null);
- try {
- const r = await apiFetch('/api/admin/telemetry', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ action: 'send-now' }),
- });
- const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
- setSendResult({
- ok: !!j.ok,
- msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
- });
- await refresh();
- } finally { setBusy(null); }
- }
-
- if (loading || !status) {
- return (
-
- loading…
-
- );
- }
-
- const envOverridden = status.consentSource === 'env';
- const isOn = status.consent === 'on';
-
- return (
-
-
- Anonymous Usage Stats
-
- Bulwark can send one anonymous heartbeat per day so we can see how many instances are
- running, on what platforms, and which features they use. It's off by
- default ; one click below enables it and helps us make the product better. No
- email addresses, no hostnames, no IPs are sent.{' '}
-
- Full schema and policy
-
-
-
-
-
-
-
-
Status
-
- {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
- {status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
- {status.consent === 'off' && 'Heartbeats are off (default).'}
- {envOverridden && (
- <> Locked by BULWARK_TELEMETRY env var.>
- )}
-
-
-
- void setConsent('on')}
- className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
- >
- Enable
-
- void setConsent('off')}
- className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
- >
- Disable
-
-
-
-
- Last sent
- {timeAgo(status.lastSentAt)}
- Next scheduled
- {timeAgo(status.nextScheduledAt)}
- Consented at
- {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
-
-
-
-
- Account activity
-
- Unique accounts that have logged in over the last 90 days. Identities are stored as a
- per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
- heartbeat as bucketed ranges.
-
-
- Total (90d)
- {status.accountCounts?.total ?? 0}
- Active (7d)
- {status.accountCounts?.active7d ?? 0}
-
-
-
-
- Endpoint
-
- Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
- (open source at bulwarkmail/dashboard) or clear this field to disable sending.
-
-
- setEndpointDraft(e.target.value)}
- placeholder={status.defaultEndpoint}
- className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
- />
- void saveEndpoint()}
- className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
- >
- Save
-
-
-
-
-
-
-
-
Payload preview
-
- Exactly what the next heartbeat would send from this install, right now.
-
-
-
void sendNow()}
- className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
- >
- Send now
-
-
- {sendResult && (
-
- {sendResult.ok ? : }
- {sendResult.msg}
-
- )}
-
- {JSON.stringify(status.payloadPreview, null, 2)}
-
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/themes.tsx b/app/(main)/admin/_tabs/themes.tsx
deleted file mode 100644
index 95ce4cf78..000000000
--- a/app/(main)/admin/_tabs/themes.tsx
+++ /dev/null
@@ -1,548 +0,0 @@
-'use client';
-
-import { useEffect, useState, useRef } from 'react';
-import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from '@/components/icons';
-import type { SettingsPolicy } from '@/lib/admin/types';
-import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
-import { apiFetch } from '@/lib/browser-navigation';
-import { BUILTIN_THEMES } from '@/lib/builtin-themes';
-
-// Derive from the single source of truth so newly added built-in themes show
-// up here automatically (was previously a hardcoded subset — see #496).
-const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
-
-interface ThemeEntry {
- id: string;
- name: string;
- version: string;
- author: string;
- description: string;
- variants: string[];
- enabled: boolean;
- forceEnabled?: boolean;
- installedAt: string;
- updatedAt: string;
-}
-
-export function ThemesTab() {
- const [themes, setThemes] = useState([]);
- const [loading, setLoading] = useState(true);
- const [uploading, setUploading] = useState(false);
- const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
- const fileInputRef = useRef(null);
- const [policy, setPolicy] = useState({ ...DEFAULT_POLICY });
- const [policyDirty, setPolicyDirty] = useState(false);
- const [savingPolicy, setSavingPolicy] = useState(false);
-
- useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
-
- async function fetchPolicy() {
- try {
- const res = await apiFetch('/api/admin/policy');
- if (res.ok) {
- const data = await res.json();
- setPolicy({
- ...data,
- themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
- });
- }
- } catch { /* ignore */ }
- }
-
- function toggleThemesEnabled() {
- setPolicy(prev => ({
- ...prev,
- features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
- }));
- setPolicyDirty(true);
- setMessage(null);
- }
-
- function toggleUserThemeUploads() {
- setPolicy(prev => ({
- ...prev,
- features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
- }));
- setPolicyDirty(true);
- setMessage(null);
- }
-
- function toggleBuiltinTheme(themeId: string) {
- setPolicy(prev => {
- const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
- const isDisabled = disabled.includes(themeId);
- return {
- ...prev,
- themePolicy: {
- ...DEFAULT_THEME_POLICY,
- ...prev.themePolicy,
- disabledBuiltinThemes: isDisabled
- ? disabled.filter((id: string) => id !== themeId)
- : [...disabled, themeId],
- },
- };
- });
- setPolicyDirty(true);
- setMessage(null);
- }
-
- function toggleAdminTheme(themeId: string) {
- setPolicy(prev => {
- const disabled = prev.themePolicy?.disabledThemes || [];
- const isDisabled = disabled.includes(themeId);
- return {
- ...prev,
- themePolicy: {
- ...DEFAULT_THEME_POLICY,
- ...prev.themePolicy,
- disabledThemes: isDisabled
- ? disabled.filter((id: string) => id !== themeId)
- : [...disabled, themeId],
- },
- };
- });
- setPolicyDirty(true);
- setMessage(null);
- }
-
- function setDefaultTheme(themeId: string | null) {
- setPolicy(prev => ({
- ...prev,
- themePolicy: {
- ...DEFAULT_THEME_POLICY,
- ...prev.themePolicy,
- defaultThemeId: themeId,
- },
- }));
- setPolicyDirty(true);
- setMessage(null);
- }
-
- async function handleSavePolicy() {
- setSavingPolicy(true);
- setMessage(null);
- try {
- const res = await apiFetch('/api/admin/policy', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(policy),
- });
- if (res.ok) {
- setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
- setPolicyDirty(false);
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
- }
- } catch {
- setMessage({ type: 'error', text: 'Failed to save policy' });
- } finally {
- setSavingPolicy(false);
- }
- }
-
- async function fetchThemes() {
- setLoading(true);
- try {
- const res = await apiFetch('/api/admin/themes');
- if (res.ok) setThemes(await res.json());
- } finally {
- setLoading(false);
- }
- }
-
- async function handleUpload(e: React.ChangeEvent) {
- const file = e.target.files?.[0];
- if (!file) return;
-
- setUploading(true);
- setMessage(null);
-
- const formData = new FormData();
- formData.append('file', file);
-
- try {
- const res = await apiFetch('/api/admin/themes', {
- method: 'POST',
- body: formData,
- });
-
- const data = await res.json();
- if (res.ok) {
- const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
- setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
- await fetchThemes();
- } else {
- setMessage({ type: 'error', text: data.error || 'Upload failed' });
- }
- } catch {
- setMessage({ type: 'error', text: 'Upload failed' });
- } finally {
- setUploading(false);
- if (fileInputRef.current) fileInputRef.current.value = '';
- }
- }
-
- async function toggleTheme(id: string, enabled: boolean) {
- setMessage(null);
- const res = await apiFetch('/api/admin/themes', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id, enabled }),
- });
-
- if (res.ok) {
- setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Update failed' });
- }
- }
-
- async function toggleForceEnabled(id: string, forceEnabled: boolean) {
- setMessage(null);
- const body: Record = { id, forceEnabled };
- if (forceEnabled) body.enabled = true;
-
- const res = await apiFetch('/api/admin/themes', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
-
- if (res.ok) {
- setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
- setPolicy(prev => {
- const current = prev.forceEnabledThemes || [];
- return {
- ...prev,
- forceEnabledThemes: forceEnabled
- ? [...current.filter(tid => tid !== id), id]
- : current.filter(tid => tid !== id),
- };
- });
- setPolicyDirty(true);
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Update failed' });
- }
- }
-
- async function forceEnableAll() {
- setMessage(null);
- const disabled = themes.filter(t => !t.enabled);
- if (disabled.length === 0) {
- setMessage({ type: 'success', text: 'All themes are already enabled' });
- return;
- }
- let failed = 0;
- for (const t of disabled) {
- const res = await apiFetch('/api/admin/themes', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: t.id, enabled: true }),
- });
- if (!res.ok) failed++;
- }
- if (failed === 0) {
- await fetchThemes();
- setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
- } else {
- await fetchThemes();
- setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
- }
- }
-
- async function forceDisableAll() {
- setMessage(null);
- const enabled = themes.filter(t => t.enabled);
- if (enabled.length === 0) {
- setMessage({ type: 'success', text: 'All themes are already disabled' });
- return;
- }
- let failed = 0;
- for (const t of enabled) {
- const res = await apiFetch('/api/admin/themes', {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id: t.id, enabled: false }),
- });
- if (!res.ok) failed++;
- }
- if (failed === 0) {
- await fetchThemes();
- setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
- } else {
- await fetchThemes();
- setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
- }
- }
-
- async function deleteTheme(id: string, name: string) {
- if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
-
- setMessage(null);
- const res = await apiFetch('/api/admin/themes', {
- method: 'DELETE',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ id }),
- });
-
- if (res.ok) {
- setThemes(prev => prev.filter(t => t.id !== id));
- setMessage({ type: 'success', text: `Theme "${name}" removed` });
- } else {
- const data = await res.json();
- setMessage({ type: 'error', text: data.error || 'Delete failed' });
- }
- }
-
- if (loading) {
- return Loading...
;
- }
-
- const themesEnabled = policy.features.themesEnabled ?? true;
- const userThemesEnabled = policy.features.userThemesEnabled ?? true;
-
- return (
-
-
-
-
Themes
-
Manage themes and theme policy for all users
-
-
- {policyDirty && (
-
- {savingPolicy ? : }
- Save Policy
-
- )}
-
- {uploading ? : }
- Upload Theme
-
-
-
-
-
- {message && (
-
- {message.text}
-
- )}
-
-
-
-
-
-
Theme Policy
-
-
Control theme availability and defaults for users
-
-
-
-
-
-
Themes Enabled
-
Allow users to select and apply themes
-
-
-
-
-
-
-
-
-
User Theme Uploads
-
Allow users to upload their own theme files
-
-
-
-
-
-
- {themes.length > 0 && (
-
-
-
Force Enable / Disable All
-
Bulk toggle all deployed themes at once
-
-
-
-
- Enable All
-
-
-
- Disable All
-
-
-
- )}
-
-
-
-
-
Default Theme
-
Theme applied when users have not chosen one
-
-
setDefaultTheme(e.target.value || null)}
- className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
- >
- System Default
-
- {BUILTIN_THEME_OPTIONS
- .filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
- .map(t => (
- {t.name}
- ))}
-
- {themes.length > 0 && (
-
- {themes
- .filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
- .map(t => (
- {t.name}
- ))}
-
- )}
-
-
-
-
-
-
Built-in Themes
-
- {BUILTIN_THEME_OPTIONS.map(theme => {
- const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
- return (
-
- {theme.name}
- toggleBuiltinTheme(theme.id)}
- className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
-
-
-
- );
- })}
-
-
-
- {themes.length > 0 && (
-
-
Admin-deployed Themes
-
- {themes.map(theme => {
- const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
- return (
-
- {theme.name}
- toggleAdminTheme(theme.id)}
- className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
-
-
-
- );
- })}
-
-
- )}
-
-
-
-
-
-
-
Admin-uploaded themes available to all users
-
- {themes.length === 0 ? (
-
-
-
No themes installed
-
Upload a theme ZIP file to get started
-
- ) : (
-
- {themes.map(theme => (
-
-
-
- {theme.name}
- v{theme.version}
-
- {theme.enabled ? 'Enabled' : 'Disabled'}
-
- {theme.forceEnabled && (
-
- Forced
-
- )}
-
- {theme.description && (
-
{theme.description}
- )}
-
- by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
-
-
-
-
-
toggleForceEnabled(theme.id, !theme.forceEnabled)}
- title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
- className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
- >
- {theme.forceEnabled ? : }
-
-
toggleTheme(theme.id, !theme.enabled)}
- title={theme.enabled ? 'Disable' : 'Enable'}
- className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
- >
-
-
-
deleteTheme(theme.id, theme.name)}
- title="Remove"
- className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
- >
-
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/app/(main)/admin/_tabs/version.tsx b/app/(main)/admin/_tabs/version.tsx
deleted file mode 100644
index 9fed85b90..000000000
--- a/app/(main)/admin/_tabs/version.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import {
- Loader2,
- RefreshCw,
- CheckCircle2,
- AlertTriangle,
- ShieldAlert,
- ExternalLink,
-} from '@/components/icons';
-import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
-import { apiFetch } from '@/lib/browser-navigation';
-import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
-
-interface VersionAdminStatus {
- current: string;
- build: string;
- endpoint: string;
- defaultEndpoint: string;
- disabledByEnv: boolean;
- lastCheckedAt: string | null;
- lastSuccessAt: string | null;
- nextScheduledAt: string | null;
- status: UpdateStatus | null;
-}
-
-function timeAgo(iso: string | null): string {
- if (!iso) return 'never';
- const d = Date.now() - new Date(iso).getTime();
- if (d < 0) return new Date(iso).toLocaleString();
- const m = Math.floor(d / 60000);
- if (m < 1) return 'just now';
- if (m < 60) return `${m} min ago`;
- const h = Math.floor(m / 60);
- if (h < 48) return `${h} hours ago`;
- return `${Math.floor(h / 24)} days ago`;
-}
-
-function severityChip(severity: UpdateSeverity) {
- switch (severity) {
- case 'security':
- return {
- label: 'Security update',
- className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
- Icon: ShieldAlert,
- };
- case 'deprecated':
- return {
- label: 'Deprecated',
- className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
- Icon: ShieldAlert,
- };
- case 'normal':
- return {
- label: 'Update available',
- className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
- Icon: AlertTriangle,
- };
- case 'unknown':
- return {
- label: 'Unknown',
- className: 'bg-muted text-muted-foreground border-border',
- Icon: AlertTriangle,
- };
- case 'none':
- default:
- return {
- label: 'Up to date',
- className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
- Icon: CheckCircle2,
- };
- }
-}
-
-export function VersionTab() {
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [checking, setChecking] = useState(false);
- const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
-
- async function refresh(): Promise {
- setLoading(true);
- try {
- const r = await apiFetch('/api/admin/version');
- if (!r.ok) throw new Error('failed to load');
- setData((await r.json()) as VersionAdminStatus);
- } catch (err) {
- console.error(err);
- } finally {
- setLoading(false);
- }
- }
- useEffect(() => { void refresh(); }, []);
-
- async function checkNow(): Promise {
- setChecking(true);
- setCheckResult(null);
- try {
- const r = await apiFetch('/api/admin/version', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ action: 'check-now' }),
- });
- const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
- setCheckResult({
- ok: !!j.ok,
- msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
- });
- await refresh();
- } finally {
- setChecking(false);
- }
- }
-
- if (loading || !data) {
- return (
-
- loading…
-
- );
- }
-
- const status = data.status;
- const chip = severityChip(status?.severity ?? 'none');
- const ChipIcon = chip.Icon;
- const releaseUrl = status?.url ?? null;
- const newer = status?.latest && status.latest !== data.current ? status.latest : null;
-
- return (
-
-
-
-
Version
-
- Hourly check against the Bulwark version server. Severity is decided server-side and
- disable with BULWARK_UPDATE_CHECK=off.
-
-
-
void checkNow()}
- className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
- >
- {checking ? : }
- Check now
-
-
-
- {checkResult && (
-
- {checkResult.msg}
-
- )}
-
-
-
-
-
- {chip.label}
-
-
-
- {data.current}
-
- {newer && (
-
- {releaseUrl ? (
-
- {newer}
-
- ) : (
- {newer}
- )}
-
- )}
- {status?.advisory && (
-
- {status.advisory}
-
- )}
-
-
-
-
- {timeAgo(data.lastCheckedAt)}
-
-
- {timeAgo(data.lastSuccessAt)}
-
-
- {timeAgo(data.nextScheduledAt)}
-
- {status?.checkedAt && (
-
- {new Date(status.checkedAt).toLocaleString()}
-
- )}
-
-
-
-
-
- {data.endpoint}
-
-
-
-
- {data.disabledByEnv ? 'Yes' : 'No'}
-
-
-
-
- );
-}
diff --git a/app/(main)/admin/_unsaved-changes-guard.tsx b/app/(main)/admin/_unsaved-changes-guard.tsx
new file mode 100644
index 000000000..ed07cfa58
--- /dev/null
+++ b/app/(main)/admin/_unsaved-changes-guard.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { useEffect, useId } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from '@/components/icons';
+import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store';
+
+/**
+ * Asks before the admin leaves a section with unsaved edits (#1255). Tab
+ * buttons and sign out go through requestLeave themselves; links anywhere on
+ * the page are caught here, and closing or reloading the tab gets the
+ * browser's own prompt.
+ */
+export function UnsavedChangesGuard() {
+ const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved);
+ const pendingLeave = useAdminUnsavedStore((s) => s.pendingLeave);
+ const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
+ const confirmLeave = useAdminUnsavedStore((s) => s.confirmLeave);
+ const cancelLeave = useAdminUnsavedStore((s) => s.cancelLeave);
+ const isOpen = pendingLeave !== null;
+ const id = useId();
+
+ const dialogRef = useFocusTrap({ isActive: isOpen, restoreFocus: true });
+
+ // On document, not the dialog, so Escape means "Stay" wherever focus is.
+ useEffect(() => {
+ if (!isOpen) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ e.preventDefault();
+ e.stopPropagation();
+ cancelLeave();
+ };
+ document.addEventListener('keydown', onKeyDown, true);
+ return () => document.removeEventListener('keydown', onKeyDown, true);
+ }, [isOpen, cancelLeave]);
+
+ useEffect(() => {
+ if (!hasUnsaved) return;
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
+ e.preventDefault();
+ e.returnValue = '';
+ };
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
+ }, [hasUnsaved]);
+
+ useEffect(() => {
+ if (!hasUnsaved) return;
+ // Capture phase on document runs before React's root listener, so a
+ // Next never starts its client-side navigation.
+ const onClick = (e: MouseEvent) => {
+ if (e.defaultPrevented || e.button !== 0) return;
+ // Read the store, not the render: after "Leave" the click is replayed
+ // before React has removed this listener.
+ if (!selectHasUnsaved(useAdminUnsavedStore.getState())) return;
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const anchor = (e.target as Element | null)?.closest?.('a[href]');
+ if (!(anchor instanceof HTMLAnchorElement)) return;
+ if (anchor.target && anchor.target !== '_self') return;
+ if (anchor.hasAttribute('download')) return;
+ const url = new URL(anchor.href, window.location.href);
+ if (url.origin === window.location.origin
+ && url.pathname === window.location.pathname
+ && url.search === window.location.search) return;
+ e.preventDefault();
+ e.stopPropagation();
+ requestLeave(() => {
+ // Nothing is dirty any more, so the second click goes through.
+ if (anchor.isConnected) anchor.click();
+ else window.location.assign(anchor.href);
+ });
+ };
+ document.addEventListener('click', onClick, true);
+ return () => document.removeEventListener('click', onClick, true);
+ }, [hasUnsaved, requestLeave]);
+
+ if (!isOpen) return null;
+
+ return (
+ { if (e.target === e.currentTarget) cancelLeave(); }}
+ >
+
+
+
+
+
+ Unsaved changes
+
+
+ You have changes that haven't been saved. If you leave now, they will be lost.
+
+
+
+
+ Stay
+ Leave
+
+
+
+ );
+}
diff --git a/app/(main)/admin/auth/page.tsx b/app/(main)/admin/auth/page.tsx
deleted file mode 100644
index a637439af..000000000
--- a/app/(main)/admin/auth/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function Page() {
- redirect('/admin?tab=auth');
-}
diff --git a/app/(main)/admin/branding/page.tsx b/app/(main)/admin/branding/page.tsx
deleted file mode 100644
index cc6f73b7d..000000000
--- a/app/(main)/admin/branding/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function Page() {
- redirect('/admin?tab=branding');
-}
diff --git a/app/(main)/admin/change-password/page.tsx b/app/(main)/admin/change-password/page.tsx
index a58206b1f..2881c69cf 100644
--- a/app/(main)/admin/change-password/page.tsx
+++ b/app/(main)/admin/change-password/page.tsx
@@ -2,29 +2,32 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
-import { Lock } from '@/components/icons';
+import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
+import { Button } from '@/components/ui/button';
+import { AdminPageHeader } from '@/components/admin/page-header';
+import { Flash, useFlash } from '@/components/admin/flash-message';
+import { inputClass } from '@/components/admin/config-items';
+import { cn } from '@/lib/utils';
export default function ChangePasswordPage() {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
- const [error, setError] = useState('');
- const [success, setSuccess] = useState(false);
+ const [message, setMessage] = useFlash();
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
- setError('');
- setSuccess(false);
+ setMessage(null);
if (newPassword.length < 8) {
- setError('New password must be at least 8 characters.');
+ setMessage({ type: 'error', text: 'The new password must be at least 8 characters.' });
return;
}
if (newPassword !== confirmPassword) {
- setError('New passwords do not match.');
+ setMessage({ type: 'error', text: 'The new passwords do not match.' });
return;
}
@@ -36,81 +39,51 @@ export default function ChangePasswordPage() {
});
if (res.ok) {
- setSuccess(true);
+ setMessage({ type: 'success', text: 'Password changed. Taking you back to the overview.' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => router.push('/admin'), 2000);
} else {
const data = await res.json().catch(() => ({}));
- setError(data.error || 'Failed to change password.');
+ setMessage({ type: 'error', text: data.error || 'Could not change the password.' });
}
setLoading(false);
}
+ const fields = [
+ { id: 'current', label: 'Current password', value: currentPassword, set: setCurrentPassword, autoComplete: 'current-password' },
+ { id: 'new', label: 'New password', value: newPassword, set: setNewPassword, autoComplete: 'new-password' },
+ { id: 'confirm', label: 'Confirm new password', value: confirmPassword, set: setConfirmPassword, autoComplete: 'new-password' },
+ ] as const;
+
return (
-
-
-
Change Password
-
Update your admin password.
-
+
+
+
+
setMessage(null)} />