mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'main' into feat/stalwart-1.0
# Conflicts: # components/contacts/contacts-app.tsx # components/email/__tests__/composer-discard.test.tsx # components/email/calendar-invitation-banner.tsx # components/email/email-composer.tsx # components/mail/mail-app.tsx # components/pro/pro-compose-tab-body.tsx # components/pro/pro-email-tab-body.tsx # components/ui/__tests__/avatar.test.tsx # components/ui/avatar.tsx # hooks/use-mailbox-drop.ts # lib/__tests__/calendar-alerts.test.ts # lib/__tests__/jmap-calendar-tasks.test.ts # lib/__tests__/jmap-send-threading.test.ts # lib/demo/demo-client.ts # lib/demo/fixtures/calendars.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/web-push.ts # stores/__tests__/contact-store.test.ts # stores/__tests__/email-store-thread-key.test.ts # stores/calendar-store.ts # stores/contact-store.ts # stores/email-store.ts
This commit is contained in:
@@ -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
|
||||
|
||||
+16
-5
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
/.next/
|
||||
/out/
|
||||
/lite-image/
|
||||
# scripts/build-plugin-sandbox.mjs
|
||||
/public/plugin-sandbox-assets/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+312
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,16 +40,16 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https
|
||||
|
||||
<p align="center">
|
||||
<a href="https://bulwarkmail.org/docs/features/calendar"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-calendar-week.webp" /><img src="screenshots/light-calendar-week.webp" alt="Calendar week view with events from several calendars" width="49%" /></picture></a>
|
||||
<a href="https://bulwarkmail.org/docs/features/contacts"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-contact.webp" /><img src="screenshots/light-contact.webp" alt="Contact details with recent mail and upcoming events" width="49%" /></picture></a>
|
||||
<a href="https://bulwarkmail.org/docs/features/contacts"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-contacts-detail.webp" /><img src="screenshots/light-contacts-detail.webp" alt="Contact details with recent mail and upcoming events" width="49%" /></picture></a>
|
||||
<br />
|
||||
<sub>Calendar week view · Contact details with recent mail and upcoming events</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://bulwarkmail.org/docs/features/email/composing"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-composer.webp" /><img src="screenshots/light-composer.webp" alt="Rich-text composer next to the message list" width="49%" /></picture></a>
|
||||
<a href="https://bulwarkmail.org/docs/features/email/composing"><picture><source media="(prefers-color-scheme: dark)" srcset="screenshots/dark-composer-mention.webp" /><img src="screenshots/light-composer-mention.webp" alt="The composer with an @mention chip and the contact card it opens" width="49%" /></picture></a>
|
||||
<a href="https://bulwarkmail.org/docs/guides/customization"><img src="screenshots/split-inbox.webp" alt="The inbox split diagonally between the light and dark theme" width="49%" /></a>
|
||||
<br />
|
||||
<sub>Composer with identities and formatting · Light and dark themes</sub>
|
||||
<sub>Composer with @mention cards · Light and dark themes</sub>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<MobileHandoffConsent
|
||||
url={pendingHandoff.url}
|
||||
username={pendingHandoff.username}
|
||||
onCancel={() => {
|
||||
setPendingHandoff(null);
|
||||
router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
{/* Theme toggle */}
|
||||
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
|
||||
<div className="absolute top-5 right-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
@@ -1108,8 +1128,16 @@ function LoginPageContent() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
{/* Theme toggle - top right, dropdown style */}
|
||||
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||
<MobileHandoffConsent
|
||||
url={pendingHandoff?.url ?? null}
|
||||
username={pendingHandoff?.username ?? ""}
|
||||
onCancel={() => {
|
||||
setPendingHandoff(null);
|
||||
router.push('/');
|
||||
}}
|
||||
/>
|
||||
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
|
||||
<div className="absolute top-5 right-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAdminLocation, navigateAdmin } from '@/hooks/use-admin-navigation';
|
||||
import { adminPath, isAdminPageId } from '@/lib/admin/admin-nav';
|
||||
import { DashboardPage } from '../_pages/dashboard';
|
||||
import { SettingsPage } from '../_pages/settings';
|
||||
import { BrandingPage } from '../_pages/branding';
|
||||
import { AuthPage } from '../_pages/auth';
|
||||
import { PolicyPage } from '../_pages/policy';
|
||||
import { PluginsPage } from '../_pages/plugins';
|
||||
import { ThemesPage } from '../_pages/themes';
|
||||
import { MarketplacePage } from '../_pages/marketplace';
|
||||
import { MarketplaceDetailPage } from '../_pages/marketplace-detail';
|
||||
import { ExtensionDetailPage } from '../_pages/extension-detail';
|
||||
import { VersionPage } from '../_pages/version';
|
||||
import { TelemetryPage } from '../_pages/telemetry';
|
||||
import { LogsPage } from '../_pages/logs';
|
||||
|
||||
/**
|
||||
* Every admin page, picked from the address bar. One route for the whole
|
||||
* panel: the sidebar switches pages with history.pushState and this
|
||||
* component re-renders from `usePathname()`, so there is never a route
|
||||
* transition between pages (lib/admin/admin-nav.ts).
|
||||
*/
|
||||
export default function AdminPage() {
|
||||
const { page, detail } = useAdminLocation();
|
||||
|
||||
// Links from before the pages had URLs: /admin?tab=settings.
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const tab = url.searchParams.get('tab');
|
||||
if (!isAdminPageId(tab)) return;
|
||||
url.searchParams.delete('tab');
|
||||
navigateAdmin(`${adminPath(tab)}${url.search}${url.hash}`, { replace: true });
|
||||
}, []);
|
||||
|
||||
switch (page ?? 'dashboard') {
|
||||
case 'dashboard': return <DashboardPage />;
|
||||
case 'settings': return <SettingsPage />;
|
||||
case 'branding': return <BrandingPage />;
|
||||
case 'auth': return <AuthPage />;
|
||||
case 'policy': return <PolicyPage />;
|
||||
case 'plugins': return detail ? <ExtensionDetailPage key={detail} kind="plugin" id={detail} /> : <PluginsPage />;
|
||||
case 'themes': return detail ? <ExtensionDetailPage key={detail} kind="theme" id={detail} /> : <ThemesPage />;
|
||||
case 'marketplace': return detail ? <MarketplaceDetailPage key={detail} slug={detail} /> : <MarketplacePage />;
|
||||
case 'version': return <VersionPage />;
|
||||
case 'telemetry': return <TelemetryPage />;
|
||||
case 'logs': return <LogsPage />;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> }> = [];
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(<><UnsavedChangesGuard /><DirtySection dirty={false} /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
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(<><UnsavedChangesGuard /><DirtySection id="plugins" dirty /><DirtySection id="plugin-config" dirty={false} /></>);
|
||||
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(
|
||||
<>
|
||||
<UnsavedChangesGuard />
|
||||
<DirtySection dirty />
|
||||
<a href="/admin/change-password" onClick={onLinkClick}>Change Password</a>
|
||||
</>,
|
||||
);
|
||||
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
|
||||
const dirtyEvent = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(dirtyEvent);
|
||||
expect(dirtyEvent.defaultPrevented).toBe(true);
|
||||
|
||||
rerender(<><UnsavedChangesGuard /><DirtySection dirty={false} /></>);
|
||||
const cleanEvent = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(cleanEvent);
|
||||
expect(cleanEvent.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(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 (
|
||||
<>
|
||||
<UnsavedChangesGuard />
|
||||
<AdminShell
|
||||
authenticated={authenticated === true}
|
||||
isStalwartAutoLogin={isStalwartAutoLogin}
|
||||
configReadOnly={configReadOnly}
|
||||
onLogout={handleLogout}
|
||||
>
|
||||
{authError ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p className="font-medium">Admin authentication failed</p>
|
||||
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||
</div>
|
||||
) : authenticated === null ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||
Loading admin panel…
|
||||
</div>
|
||||
) : authenticated ? children : null}
|
||||
</AdminShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+68
-50
@@ -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<RowDraft[]>(() => value.map(entryToDraft));
|
||||
const lastEmittedRef = useRef(value);
|
||||
@@ -119,38 +138,21 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||
{source && source !== 'default' && (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
|
||||
<div className="space-y-3 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 sm:pe-4">
|
||||
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP server URL</code> above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{source === 'admin' && (
|
||||
<button
|
||||
onClick={onRevert}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Revert to default"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={add}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<SourceBadge source={source} />
|
||||
{source === 'admin' && <RevertButton onClick={onRevert} />}
|
||||
<Button type="button" variant="outline" size="sm" className="gap-1.5" onClick={add}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add server
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,40 +166,42 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
|
||||
<label className={fieldLabelClass}>ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.id}
|
||||
onChange={(e) => 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 && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
|
||||
<label className={fieldLabelClass}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.label}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
|
||||
<label className={fieldLabelClass}>JMAP URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={d.url}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -205,7 +209,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||
<label className={fieldLabelClass}>
|
||||
Email domains (comma-separated, used for auto-pick)
|
||||
</label>
|
||||
<input
|
||||
@@ -213,7 +217,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
value={d.domains}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -227,47 +231,61 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<label className={fieldLabelClass}>OAuth Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.oauthClientId}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
|
||||
<label className={fieldLabelClass}>OAuth Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={d.oauthIssuerUrl}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
|
||||
<label className={fieldLabelClass}>OAuth Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={d.oauthClientSecret}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Sign-in button label</label>
|
||||
<label className={fieldLabelClass}>Sign-in button label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.oauthButtonLabel}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className={fieldLabelClass}>Provider logout URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={d.oauthEndSessionUrl}
|
||||
onChange={(e) => update(i, { oauthEndSessionUrl: e.target.value })}
|
||||
placeholder="https://auth.example.com/logout"
|
||||
className={fieldClass}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
{setupOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-setup-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||
Webmail origin
|
||||
</label>
|
||||
<input
|
||||
id="setup-origin"
|
||||
type="url"
|
||||
value={setupOrigin}
|
||||
onChange={(e) => setSetupOrigin(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://webmail.example.com"
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||
</p>
|
||||
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||
Stalwart issuer URL
|
||||
</label>
|
||||
<input
|
||||
id="setup-issuer"
|
||||
type="url"
|
||||
value={setupIssuer}
|
||||
onChange={(e) => setSetupIssuer(e.target.value)}
|
||||
disabled={setupRunning}
|
||||
placeholder="https://mail.example.com"
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||
</p>
|
||||
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupOauthOnly}
|
||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-input"
|
||||
disabled={setupRunning}
|
||||
/>
|
||||
Also enable “OAuth only” (hide password login)
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setSetupOpen(false)} disabled={setupRunning}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleAutoSetup}
|
||||
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
<SettingsSection id="oauth" title="OAuth / OpenID Connect">
|
||||
<SettingItem
|
||||
label="Set up with Stalwart"
|
||||
description="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."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button type="button" size="sm" className="gap-2" onClick={openSetupDialog} disabled={setupRunning || configReadOnly}>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</Button>
|
||||
{configReadOnly && (
|
||||
<p className="text-xs text-destructive">
|
||||
Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
<ConfigToggleItem label="Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="OAuth only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="Client secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<ConfigTextItem label="Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" wide />
|
||||
<ConfigToggleItem label="Allow private endpoints" description="Permit discovered endpoints on other hosts to resolve to RFC-1918 / loopback addresses. Endpoints on the issuer's own host are always allowed, so split-DNS setups rarely need this." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="Scopes" description={`Space-separated scopes that replace the defaults (${DEFAULT_OAUTH_SCOPES}). Leave blank to use them. offline_access is not requested by default; add it here or under Extra Scopes if your provider only issues refresh tokens for it, or sessions end when the access token expires.`} configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder={DEFAULT_OAUTH_SCOPES} wide />
|
||||
<ConfigTextItem label="Extra scopes" description={`Additional space-separated scopes appended to the defaults (${DEFAULT_OAUTH_SCOPES}). Ignored when OAuth Scopes is set.`} configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." wide />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="sso" title="Single sign-on">
|
||||
<ConfigToggleItem label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="End provider session on sign-out" description="Signing out of an SSO account also signs out of the identity provider, when it advertises an end_session_endpoint or a logout URL is set below. Turn off if the provider is shared with apps that should stay signed in." configKey="oauthEndSession" value={currentValue('oauthEndSession') as boolean} source={config.oauthEndSession?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="Provider logout URL" description="For providers whose discovery has no end_session_endpoint, such as Stalwart. Used instead of the discovered endpoint when set, with the same parameters. Must be https. Applies to the global OAuth settings; servers in the server list set their own." configKey="oauthEndSessionUrl" value={currentValue('oauthEndSessionUrl') as string} source={config.oauthEndSessionUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com/logout" wide />
|
||||
<ConfigTextItem label="Post-logout redirect URI" description="Where the provider sends users after signing them out. Register it with the provider as a post-logout redirect URI first, or it will refuse the logout. Leave blank to stay on the provider's signed-out page." configKey="oauthPostLogoutRedirectUri" value={currentValue('oauthPostLogoutRedirectUri') as string} source={config.oauthPostLogoutRedirectUri?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/en/login" wide />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="admin-access" title="Admin access">
|
||||
<ConfigSelectItem
|
||||
label="Stalwart admin accounts"
|
||||
description="What a Stalwart admin account grants in this dashboard. “Automatic” signs Stalwart admins in without the admin password; “Password required” keeps the shield but asks for the admin password; “Off” ignores Stalwart admin status entirely. The last two need an admin password to be configured."
|
||||
configKey="stalwartAdminAccess"
|
||||
value={currentValue('stalwartAdminAccess') as string}
|
||||
source={config.stalwartAdminAccess?.source}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Automatic (no password)' },
|
||||
{ value: 'password', label: 'Password required' },
|
||||
{ value: 'off', label: 'Off (separate admins)' },
|
||||
]}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="session" title="Session and security">
|
||||
<ConfigSelectItem label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="Allowed frame ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." wide />
|
||||
<ConfigTextItem label="Parent origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={hasEdits}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onDiscard={() => setEdits({})}
|
||||
note="Takes effect on the next page load"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useFlash();
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||
() => 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<string, string>): 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<string, string | undefined>;
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The badge next to a field that is set in the scope being edited. */
|
||||
const overrideBadge = (key: string) => (
|
||||
isOverriddenInScope(key)
|
||||
? <Badge tone="accent">{isUploadedFile(key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}</Badge>
|
||||
: null
|
||||
);
|
||||
|
||||
const imageRow = (field: { key: string; label: string; accept: string }) => (
|
||||
<ImageItem
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
accept={field.accept}
|
||||
value={currentValue(field.key)}
|
||||
badge={overrideBadge(field.key)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
uploading={uploading === field.key}
|
||||
uploadDisabled={wildcardScope}
|
||||
uploaded={isUploadedFile(field.key)}
|
||||
overridden={isOverriddenInScope(field.key)}
|
||||
onChange={(value) => 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) => (
|
||||
<SettingItem key={field.key} label={field.label}>
|
||||
<div className="flex w-full items-center gap-1.5 sm:w-auto">
|
||||
{overrideBadge(field.key)}
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => 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) && <RevertButton onClick={() => handleRevert(field.key)} />}
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
<SettingsSection id="scope" title="Scope">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => handleScopeChange(null)} aria-pressed={selectedHost === null} className={chipClass(selectedHost === null)}>
|
||||
Default
|
||||
</button>
|
||||
{domainEntries.map(entry => (
|
||||
<button
|
||||
key={entry.host}
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(entry.host)}
|
||||
aria-pressed={selectedHost === entry.host}
|
||||
className={chipClass(selectedHost === entry.host)}
|
||||
>
|
||||
{entry.host}
|
||||
</button>
|
||||
))}
|
||||
{!addingHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add domain
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addingHost && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newHostInput}
|
||||
onChange={(e) => { 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')}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={handleAddDomain}>
|
||||
Add
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||
Unset fields fall back to the Default values.
|
||||
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDomain}
|
||||
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Remove domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="images" title="Images and logos" description="Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)">
|
||||
{IMAGE_FIELDS.map(imageRow)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="pwa" title="Progressive web app" description="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(imageRow)}
|
||||
{PWA_TEXT_FIELDS.map(field => textRow(field, field.placeholder))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<SettingItem key={field.key} label={field.label}>
|
||||
<div className="flex w-full items-center gap-1.5 sm:w-auto">
|
||||
{overrideBadge(field.key)}
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => 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) && <RevertButton onClick={() => handleRevert(field.key)} />}
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="company" title="Company information">
|
||||
{TEXT_FIELDS.map(field => textRow(field, field.key.includes('Url') ? 'https://...' : 'Enter value'))}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={hasEdits}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onDiscard={() => setEdits({})}
|
||||
note="Takes effect on the next page load"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(null);
|
||||
return (
|
||||
<SettingItem label={label}>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end">
|
||||
<div className="flex w-full items-center gap-1.5 sm:w-auto">
|
||||
{badge}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={cn(inputClass, 'w-full min-w-0 sm:w-64')}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-9 shrink-0 px-0"
|
||||
onClick={() => 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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
</Button>
|
||||
{uploaded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteUpload}
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
aria-label="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{overridden && !uploaded && <RevertButton onClick={onRevert} />}
|
||||
</div>
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(value)}
|
||||
alt={label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
@@ -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<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
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<VersionData | null>(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 (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<div className="space-y-10">
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-warning">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile
|
||||
label="Accounts"
|
||||
value={accountCounts?.total ?? '-'}
|
||||
sub={accountCounts ? `${accountCounts.active7d} active this week` : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="Plugins"
|
||||
value={pluginCount}
|
||||
href={adminPath('plugins')}
|
||||
sub={pendingApprovals > 0 ? `${pendingApprovals} awaiting approval` : undefined}
|
||||
subTone="warning"
|
||||
/>
|
||||
<StatTile label="Themes" value={themeCount} href={adminPath('themes')} />
|
||||
<StatTile
|
||||
label="Version"
|
||||
value={version?.current ?? '-'}
|
||||
href={adminPath('version')}
|
||||
sub={versionSub.text}
|
||||
subTone={versionSub.tone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSection id="server" title="Server">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last admin login">
|
||||
<span className="text-sm text-foreground">
|
||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Settings sync" description="Synchronize user settings across devices">
|
||||
<OnOff on={!!config?.settingsSyncEnabled} />
|
||||
</SettingItem>
|
||||
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||
<OnOff on={!!config?.oauthEnabled} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Stalwart integration" description="Stalwart mail server features">
|
||||
<OnOff on={config?.stalwartFeaturesEnabled !== false} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="activity" title="Recent activity">
|
||||
{recentActivity.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">No activity recorded yet</p>
|
||||
) : (
|
||||
<AdminList>
|
||||
{recentActivity.map((entry, i) => (
|
||||
<AdminListRow
|
||||
key={i}
|
||||
title={<code className="font-mono text-xs">{entry.action}</code>}
|
||||
description={formatDetail(entry.detail) || undefined}
|
||||
meta={<>{entry.ip} · {new Date(entry.ts).toLocaleString()}</>}
|
||||
/>
|
||||
))}
|
||||
</AdminList>
|
||||
)}
|
||||
<AdminLink href={adminPath('logs')} className="inline-block text-sm font-medium text-primary hover:underline">
|
||||
All entries
|
||||
</AdminLink>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<StatTone, string> = {
|
||||
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 = (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 truncate text-2xl font-semibold text-foreground">{value}</div>
|
||||
{sub && <div className={cn('mt-1 text-xs', SUB_TONE[subTone])}>{sub}</div>}
|
||||
</>
|
||||
);
|
||||
const className = 'block min-w-0 rounded-lg border border-border p-4';
|
||||
if (href) {
|
||||
return (
|
||||
<AdminLink href={href} className={cn(className, 'transition-colors hover:bg-muted/40')}>
|
||||
{body}
|
||||
</AdminLink>
|
||||
);
|
||||
}
|
||||
return <div className={className}>{body}</div>;
|
||||
}
|
||||
|
||||
function OnOff({ on }: { on: boolean }) {
|
||||
return <Badge tone={on ? 'success' : 'muted'}>{on ? 'On' : 'Off'}</Badge>;
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): 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);
|
||||
}
|
||||
@@ -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<string, ConfigField>;
|
||||
}
|
||||
|
||||
type Kind = 'plugin' | 'theme';
|
||||
|
||||
const KIND_LABEL: Record<Kind, { one: string; many: string; endpoint: string }> = {
|
||||
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<ExtensionEntry | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(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<string, string> = {};
|
||||
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<string, unknown>, what: string): Promise<boolean> {
|
||||
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<string, unknown> = { 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 (
|
||||
<div>
|
||||
<AdminPageHeader back={back} title={<span className="text-muted-foreground">Loading</span>} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader back={back} title={`${words.one[0].toUpperCase()}${words.one.slice(1)} not found`} description={<>Nothing is installed under the id <code className="font-mono text-xs">{id}</code>.</>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
back={back}
|
||||
leading={(
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
|
||||
<ExtensionIcon url={iconFor(entry)} fallback={Icon} className={iconFor(entry) ? 'h-full w-full object-cover' : 'h-5 w-5'} />
|
||||
</div>
|
||||
)}
|
||||
title={(
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{entry.name}
|
||||
<span className="text-sm font-normal text-muted-foreground">v{entry.version}</span>
|
||||
{entry.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
|
||||
{entry.dev && <Badge tone="info" title="Loaded from the dev plugins directory">Dev</Badge>}
|
||||
</span>
|
||||
)}
|
||||
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={(
|
||||
<>
|
||||
<label className="flex h-9 items-center gap-2.5 rounded-md border border-input bg-background px-3 text-sm">
|
||||
<span className={cn('font-medium', entry.enabled ? 'text-foreground' : 'text-muted-foreground')}>{entry.enabled ? 'On' : 'Off'}</span>
|
||||
<ToggleSwitch checked={entry.enabled} disabled={busy === 'enabled'} onChange={(v) => void setEnabled(v)} ariaLabel={`${entry.name} enabled`} />
|
||||
</label>
|
||||
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive" disabled={busy === 'remove'} onClick={() => void remove()}>
|
||||
{busy === 'remove' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{entry.description && <p className="-mt-2 mb-6 text-sm text-foreground">{entry.description}</p>}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SettingsSection id="availability" title="Availability" description={`Whether users get this ${words.one} and whether they can turn it off.`}>
|
||||
<SettingItem
|
||||
label="Force on for everyone"
|
||||
description={kind === 'plugin'
|
||||
? 'Users cannot turn it off in their settings. Turning this on also enables the plugin.'
|
||||
: 'Users cannot deactivate it. Turning this on also enables the theme.'}
|
||||
>
|
||||
<ToggleSwitch checked={!!entry.forceEnabled} disabled={busy === 'forced'} onChange={(v) => void setForced(v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Source" description={entry.source?.kind === 'marketplace' ? 'Installed from the extension directory; updates show up in the marketplace.' : 'Installed from an uploaded ZIP.'}>
|
||||
{entry.source?.kind === 'marketplace' ? (
|
||||
<AdminLink href={adminPath('marketplace', entry.source.slug)} className="inline-flex items-center gap-1.5 text-sm text-foreground hover:underline">
|
||||
<Store className="h-4 w-4 text-muted-foreground" /> Open in marketplace
|
||||
</AdminLink>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"><Upload className="h-4 w-4" /> Upload</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{kind === 'plugin' && (
|
||||
<SettingsSection id="permissions" title="Permissions" description={perms.length === 0 ? 'This plugin asks for no permissions.' : 'What the plugin may do in each user\'s mailbox. High-risk ones first.'}>
|
||||
{perms.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{perms.map((perm) => {
|
||||
const risky = HIGH_RISK_PERMISSIONS.has(perm);
|
||||
return (
|
||||
<li key={perm} className="flex items-center gap-2.5 px-3 py-2 text-sm">
|
||||
{risky
|
||||
? <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-label="High risk" />
|
||||
: <Shield className="h-4 w-4 shrink-0 text-muted-foreground/60" />}
|
||||
<code className="font-mono text-xs text-foreground">{perm}</code>
|
||||
{risky && <Badge tone="warning">High risk</Badge>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection id="versions" title="Versions" description={previous.length === 0 ? 'No earlier version is kept on this server.' : 'Earlier versions kept on this server can be put back without the directory.'}>
|
||||
<SettingItem label="Installed">
|
||||
<span className="font-mono text-sm text-foreground">v{entry.version}</span>
|
||||
</SettingItem>
|
||||
{previous.map((version) => (
|
||||
<SettingItem key={version} label={`v${version}`} description="Kept on this server">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" disabled={busy !== null} onClick={() => void rollback(version)}>
|
||||
{busy === `rollback:${version}` ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
||||
Roll back
|
||||
</Button>
|
||||
</SettingItem>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
{kind === 'plugin' && <PluginAdminSection pluginId={id} />}
|
||||
|
||||
{/* The plugin's own settings come last: they are the long part. */}
|
||||
{kind === 'plugin' && hasSchema && schema && (
|
||||
<SettingsSection id="settings" title="Settings" description="What this plugin reads from the server. Saved for every user.">
|
||||
{Object.entries(schema).map(([key, field]) => (
|
||||
<SettingItem
|
||||
key={key}
|
||||
label={field.required ? `${field.label} *` : field.label}
|
||||
description={field.description}
|
||||
>
|
||||
<ConfigFieldControl
|
||||
field={field}
|
||||
value={formValues[key] ?? ''}
|
||||
stored={config[key] !== undefined && config[key] !== null && config[key] !== ''}
|
||||
revealed={!!revealSecrets[key]}
|
||||
onReveal={() => setRevealSecrets((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
onSave={saveConfig}
|
||||
onDiscard={() => setFormValues(savedValues)}
|
||||
saveLabel="Save settings"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ToggleSwitch checked={(value || String(field.default ?? 'false')) === 'true'} onChange={(v) => onChange(v ? 'true' : 'false')} />
|
||||
);
|
||||
}
|
||||
if (field.type === 'select' && field.options) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} aria-label={field.label} className={cn(inputClass, width, 'cursor-pointer')}>
|
||||
<option value="">Choose</option>
|
||||
{field.options.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (field.type === 'secret') {
|
||||
return (
|
||||
<div className={cn('relative', width)}>
|
||||
<input
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReveal}
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={revealed ? 'Hide' : 'Show'}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => 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 (
|
||||
<SettingsSection id="plugin-panel" title="Plugin panel" description="Provided by the plugin itself.">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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<AuditEntry[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title={PAGE.label}
|
||||
description={<>{PAGE.description} · {total} total entries</>}
|
||||
actions={(
|
||||
<>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||
aria-label="Filter by action"
|
||||
className={cn(inputClass, 'pe-8 cursor-pointer')}
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<option value="admin.login">Login</option>
|
||||
<option value="admin.logout">Logout</option>
|
||||
<option value="admin.login_failed">Login Failed</option>
|
||||
<option value="admin.login_blocked">Login Blocked</option>
|
||||
<option value="admin.change-password">Password Change</option>
|
||||
<option value="config.update">Config Update</option>
|
||||
<option value="config.revert">Config Revert</option>
|
||||
<option value="policy.update">Policy Update</option>
|
||||
</select>
|
||||
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={fetchLogs} disabled={loading}>
|
||||
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{initialLoading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="No entries found"
|
||||
hint={actionFilter ? 'Nothing matches this action filter.' : 'Admin actions show up here as they happen.'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="sm:hidden space-y-2">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge className="font-mono text-xs truncate">{entry.action}</Badge>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-foreground break-words">
|
||||
{formatDetail(entry.detail)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block rounded-lg border border-border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{entries.map((entry, i) => (
|
||||
<tr key={i} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className="font-mono text-xs">{entry.action}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||
{formatDetail(entry.detail)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): 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<string, unknown>) => `${c.key}`).join(', ');
|
||||
}
|
||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||
return JSON.stringify(detail).slice(0, 100);
|
||||
}
|
||||
@@ -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<string, string> | 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<string, unknown> | 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<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(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<string | null | undefined>(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 (
|
||||
<div>
|
||||
<AdminPageHeader back={BACK} title={<span className="text-muted-foreground">Loading</span>} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
back={BACK}
|
||||
title={error ? 'Could not load the extension' : 'Extension not found'}
|
||||
description={error ?? <>Nothing in the directory is listed under <code className="font-mono text-xs">{slug}</code>.</>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, { type: string; label: string; description?: string; default?: unknown }> | 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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
back={BACK}
|
||||
leading={(
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
|
||||
{ext.iconUrl ? (
|
||||
<img src={ext.iconUrl} alt="" className="h-11 w-11 object-cover" loading="lazy" />
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="h-5 w-5" />
|
||||
) : (
|
||||
<SwatchBook className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
title={(
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 break-words">{ext.name}</span>
|
||||
{ext.featured && <Star className="h-4 w-4 shrink-0 fill-warning text-warning" />}
|
||||
{data.installed && (
|
||||
<Badge tone="success" icon={<Check className="h-3 w-3" />}>Installed v{data.installedVersion}</Badge>
|
||||
)}
|
||||
{updateAvailable && (
|
||||
<button type="button" onClick={() => pick(ext.latest!.version)} title={`Preview v${ext.latest!.version}`} className="rounded hover:underline">
|
||||
<Badge tone="info" icon={<ArrowUpCircle className="h-3 w-3" />}>v{ext.latest!.version} available</Badge>
|
||||
</button>
|
||||
)}
|
||||
{ext.deprecated && <Badge>Deprecated</Badge>}
|
||||
{suspended && <Badge tone="danger" icon={<Ban className="h-3 w-3" />}>Suspended</Badge>}
|
||||
</span>
|
||||
)}
|
||||
description={(
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Badge tone={isPlugin ? 'info' : 'accent'}>{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}</Badge>
|
||||
{ext.author && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
by {ext.author.displayName}
|
||||
{ext.author.verified && <ShieldCheck className="h-3.5 w-3.5" aria-label="verified author" />}
|
||||
</span>
|
||||
)}
|
||||
{ext.license && <span>{ext.license}</span>}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
{ext.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
{ext.rating.count > 0 && (
|
||||
<span className="inline-flex items-center gap-1" title={`${ext.rating.average} out of 5 from ${ext.rating.count} rating${ext.rating.count === 1 ? '' : 's'}`}>
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
{ext.rating.average.toFixed(1)} ({ext.rating.count})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
actions={(
|
||||
<>
|
||||
{preview && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={previewIsInstalled ? 'outline' : 'default'}
|
||||
className={cn('gap-1.5', previewDirection > 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}` ? <Loader2 className="h-4 w-4 animate-spin" /> : previewDirection > 0 ? <ArrowUpCircle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
{installLabel}
|
||||
</Button>
|
||||
)}
|
||||
{data.installed && (
|
||||
<>
|
||||
<AdminButtonLink href={isPlugin ? adminPath('plugins', ext.slug) : adminPath('themes', ext.slug)}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
Manage
|
||||
</AdminButtonLink>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-destructive hover:text-destructive" onClick={handleUninstall} disabled={uninstalling}>
|
||||
{uninstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<AdminButtonLink external href={ext.directoryUrl} target="_blank" rel="noopener noreferrer" title="Open this extension's page in the directory">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Directory
|
||||
</AdminButtonLink>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{ext.bannerUrl && (
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img
|
||||
src={ext.bannerUrl}
|
||||
alt=""
|
||||
className="block w-full max-h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="mb-6 space-y-3 empty:hidden">
|
||||
{data.requestedVersionMissing && (
|
||||
<Notice tone="warning" title={`The link asked for v${data.requestedVersionMissing}, which the directory no longer offers`}>
|
||||
Showing v{preview?.version} instead.
|
||||
</Notice>
|
||||
)}
|
||||
{suspended && (
|
||||
<Notice tone="danger" title="This extension was suspended by the directory">
|
||||
It cannot be installed. {data.installed ? 'Consider removing it.' : ''}
|
||||
</Notice>
|
||||
)}
|
||||
{ext.deprecated && (
|
||||
<Notice tone="info" title="This extension is deprecated">
|
||||
{ext.deprecated.message || 'Its publisher no longer maintains it.'}
|
||||
{ext.deprecated.replacement && <> Use <AdminLink href={adminPath('marketplace', ext.deprecated.replacement)} className="underline">{ext.deprecated.replacement}</AdminLink> instead.</>}
|
||||
</Notice>
|
||||
)}
|
||||
{ext.advisories.filter((a) => !a.withdrawn).map((a) => (
|
||||
<Notice key={a.id} tone="danger" title={`Security advisory ${a.id} (${a.severity})`}>
|
||||
{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 && (
|
||||
<> <b>Your installed v{data.installedVersion} is affected.</b></>
|
||||
)}
|
||||
</Notice>
|
||||
))}
|
||||
{installedRow?.yanked && (
|
||||
<Notice tone="warning" title={`The installed v${installedRow.version} was withdrawn by its publisher`}>
|
||||
{installedRow.yanked.reason || 'No reason was given.'} Pick another version below.
|
||||
</Notice>
|
||||
)}
|
||||
{preview?.yanked && !previewIsInstalled && (
|
||||
<Notice tone="warning" title={`v${preview.version} was withdrawn by its publisher`}>
|
||||
{preview.yanked.reason || 'No reason was given.'} It can still be installed, but you will be asked to confirm.
|
||||
</Notice>
|
||||
)}
|
||||
{preview && !previewFits && (
|
||||
<Notice tone="warning" title={preview.maxAppVersion && compareVersions(CURRENT_APP_VERSION, preview.maxAppVersion) > 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 && (
|
||||
<> <button type="button" className="underline" onClick={() => pick(ext.latest!.version)}>v{ext.latest.version}</button> runs here.</>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{!ext.latest && !preview && (
|
||||
<Notice tone="warning" title="No version runs on this Bulwark">
|
||||
{ext.newestVersion ? `The newest, v${ext.newestVersion}, needs Bulwark ${ext.newestMinAppVersion ?? 'a newer version'}.` : 'Nothing has been published yet.'}
|
||||
</Notice>
|
||||
)}
|
||||
{bundle.error && (
|
||||
<Notice tone="warning" title="Could not preview bundle">{bundle.error}</Notice>
|
||||
)}
|
||||
{bundle.signature?.status === 'invalid' && (
|
||||
<Notice tone="danger" title="The directory's signature on this bundle does not check out">
|
||||
{bundle.signature.reason}. It will not be installed.
|
||||
</Notice>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
<SettingsSection id="about" title="About">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-foreground">{ext.description}</p>
|
||||
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{ext.longDescription}</p>
|
||||
)}
|
||||
{ext.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ext.tags.map(tag => <Badge key={tag}>{tag}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3 text-xs text-muted-foreground">
|
||||
{preview && <span>Previewing v{preview.version}{preview.channel === 'beta' ? ' (beta)' : ''}</span>}
|
||||
{bundle.size > 0 && <span>Bundle: {fmtSize(bundle.size)}</span>}
|
||||
{bundle.signature?.status === 'verified' && (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400" title={`Signed by the directory, key ${bundle.signature.keyId}`}>
|
||||
<ShieldCheck className="h-3 w-3" /> Signed by the directory
|
||||
</span>
|
||||
)}
|
||||
{bundle.signature?.status === 'unsigned' && (
|
||||
<span className="inline-flex items-center gap-1" title="No directory root key is configured, so the signature is not checked">
|
||||
<Info className="h-3 w-3" /> Signature not checked
|
||||
</span>
|
||||
)}
|
||||
{ext.source.repo && (
|
||||
<a
|
||||
href={`https://${ext.source.host}/${ext.source.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{ext.source.host === 'github.com' ? '' : `${ext.source.host}/`}{ext.source.repo}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="versions"
|
||||
title="Versions"
|
||||
description="Every published version. Versions this server still has a copy of can be put back without the directory."
|
||||
>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className={thClass}>Version</th>
|
||||
<th className={thClass}>Released</th>
|
||||
<th className={thClass}>Needs Bulwark</th>
|
||||
<th className={thClass}>Size</th>
|
||||
<th className={thClass}>Status</th>
|
||||
<th className={cn(thClass, 'text-end')}><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{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 (
|
||||
<tr key={v.version} className={isPreview ? 'bg-muted/40' : undefined}>
|
||||
<td className={tdClass}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<code className="font-mono text-xs text-foreground">v{v.version}</code>
|
||||
{v.channel === 'beta' && <Badge tone="info">beta</Badge>}
|
||||
{v.signed && <ShieldCheck className="h-3 w-3 text-muted-foreground" aria-label="signed" />}
|
||||
</div>
|
||||
{v.changelog && <p className="mt-0.5 line-clamp-2 max-w-xs whitespace-pre-wrap text-xs text-muted-foreground">{v.changelog}</p>}
|
||||
</td>
|
||||
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>{fmtDate(v.publishedAt)}</td>
|
||||
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>
|
||||
{v.minAppVersion ?? 'any'}{v.maxAppVersion ? ` to ${v.maxAppVersion}` : v.minAppVersion ? '+' : ''}
|
||||
</td>
|
||||
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>{fmtSize(v.size)}</td>
|
||||
<td className={cn(tdClass, 'text-xs')}>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{isInstalled && <span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400"><Check className="h-3 w-3" /> Installed</span>}
|
||||
{v.yanked && <span className="inline-flex items-center gap-1 text-amber-800 dark:text-amber-300" title={v.yanked.reason ?? undefined}><AlertTriangle className="h-3 w-3" /> Withdrawn</span>}
|
||||
{!runs && <span className="text-muted-foreground">Not for v{CURRENT_APP_VERSION}</span>}
|
||||
{kept && !isInstalled && <span className="inline-flex items-center gap-1 text-muted-foreground"><RotateCcw className="h-3 w-3" /> Kept here</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{!isPreview && (
|
||||
<Button variant="ghost" size="sm" className={cn(smallButtonClass, 'text-muted-foreground hover:text-foreground')} onClick={() => pick(v.version)} title="Preview this version">
|
||||
<Eye className="h-3 w-3" /> Preview
|
||||
</Button>
|
||||
)}
|
||||
{kept && !isInstalled && data.installed && (
|
||||
<Button variant="outline" size="sm" className={smallButtonClass} onClick={() => handleRollback(v.version)} disabled={!!busy}>
|
||||
{busy === `rollback:${v.version}` ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
|
||||
Roll back
|
||||
</Button>
|
||||
)}
|
||||
{!isInstalled && !kept && runs && !suspended && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={smallButtonClass}
|
||||
onClick={() => handleInstall(v.version)}
|
||||
disabled={!!busy}
|
||||
title={v.yanked ? 'Withdrawn; you will be asked to confirm' : undefined}
|
||||
>
|
||||
{busy === key ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
{v.yanked ? 'Install anyway' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{extraHistory.map((h) => (
|
||||
<tr key={`kept-${h.version}`}>
|
||||
<td className={tdClass}>
|
||||
<code className="font-mono text-xs text-foreground">v{h.version}</code>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">No longer in the directory</p>
|
||||
</td>
|
||||
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>replaced {fmtDate(h.savedAt)}</td>
|
||||
<td className={cn(tdClass, 'text-xs text-muted-foreground')}>–</td>
|
||||
<td className={cn(tdClass, 'text-xs text-muted-foreground')}>–</td>
|
||||
<td className={cn(tdClass, 'text-xs')}>
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground"><RotateCcw className="h-3 w-3" /> Kept here</span>
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
<div className="flex justify-end">
|
||||
{data.installed && (
|
||||
<Button variant="outline" size="sm" className={smallButtonClass} onClick={() => handleRollback(h.version)} disabled={!!busy}>
|
||||
{busy === `rollback:${h.version}` ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
|
||||
Roll back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ext.versions.length === 0 && extraHistory.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-sm text-muted-foreground">No versions published.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{ext.screenshots.length > 0 && (
|
||||
<SettingsSection id="screenshots" title="Screenshots">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{ext.screenshots.map((s, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={s.url}
|
||||
alt={s.altText || `Screenshot ${i + 1}`}
|
||||
className="w-full rounded-md border border-border bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||
<SettingsSection id="theme-preview" title="Theme preview">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{ext.themePreviews.map(preview => (
|
||||
preview.url
|
||||
? (
|
||||
<figure key={preview.variant} className="overflow-hidden rounded-md border border-border">
|
||||
<img src={preview.url} alt={`${ext.name} in ${preview.variant} mode`} className="block w-full bg-muted" loading="lazy" />
|
||||
<figcaption className="border-t border-border bg-muted/30 px-3 py-2 text-xs font-medium capitalize text-muted-foreground">{preview.variant}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
: <ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{isPlugin && (
|
||||
<SettingsSection
|
||||
id="permissions"
|
||||
title="Permissions"
|
||||
description={manifestPerms.length === 0
|
||||
? 'This plugin requests no permissions.'
|
||||
: `What ${preview ? `v${preview.version}` : 'the plugin'} may do in each user's mailbox.`}
|
||||
>
|
||||
{manifestPerms.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{manifestPerms.map(perm => {
|
||||
const text = permissionText.get(perm);
|
||||
const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm);
|
||||
return (
|
||||
<li
|
||||
key={perm}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-md px-2 py-1.5 text-sm',
|
||||
risky
|
||||
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||
: 'bg-muted/50 text-foreground',
|
||||
)}
|
||||
>
|
||||
{risky ? <ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : <Shield className="mt-0.5 h-3.5 w-3.5 shrink-0 opacity-50" />}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{text && <span className="font-medium">{text.title}</span>}
|
||||
<code className="font-mono text-xs opacity-80">{perm}</code>
|
||||
</div>
|
||||
{text?.body && <p className="mt-0.5 text-xs opacity-90">{text.body}</p>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{frameOrigins.length > 0 && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<h4 className="text-xs font-medium text-foreground">Iframe origins</h4>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
The plugin will be allowed to embed content from these origins.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{frameOrigins.map(origin => (
|
||||
<li key={origin} className="rounded bg-muted/50 px-2 py-1 font-mono text-xs text-foreground">
|
||||
{origin}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||
<SettingsSection id="user-settings" title="User settings" description="Settings users will be able to configure after install.">
|
||||
<ul className="divide-y divide-border">
|
||||
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||
<li key={key} className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs text-foreground">{key}</code>
|
||||
<Badge>{field.type}</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-foreground">{field.label}</div>
|
||||
{field.description && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{field.description}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{bundle.manifest && (
|
||||
<SettingsSection id="manifest" title="Manifest">
|
||||
<div className="rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">manifest.json</span>
|
||||
</span>
|
||||
{showManifest ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showManifest && (
|
||||
<pre className="overflow-x-auto whitespace-pre px-4 pb-4 font-mono text-xs text-foreground">
|
||||
{JSON.stringify(bundle.manifest, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{bundle.source && (
|
||||
<SettingsSection id="source" title="Source">
|
||||
<div className="rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{bundle.source.name}</span>
|
||||
{bundle.source.truncated && <Badge tone="warning">truncated</Badge>}
|
||||
</span>
|
||||
{showSource ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showSource && (
|
||||
<pre className="max-h-[600px] overflow-x-auto overflow-y-auto whitespace-pre px-4 pb-4 font-mono text-xs text-foreground">
|
||||
{bundle.source.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm', NOTICE_TONES[tone])}>
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{title}</p>
|
||||
{children && <p className="mt-0.5 text-xs opacity-90">{children}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | 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 (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||
{preview.variant}
|
||||
</div>
|
||||
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||
</div>
|
||||
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||
<span style={{ color: fg }}>Card surface</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
title={`${key}: ${value}`}
|
||||
className="inline-block w-4 h-4 rounded border"
|
||||
style={{ background: value, borderColor: border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MarketplaceListing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [sort, setSort] = useState<Sort>('created');
|
||||
const [view, setView] = useState<View>('browse');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [perPage] = useState(12);
|
||||
const [directoryUrl, setDirectoryUrl] = useState<string | null>(null);
|
||||
const [channel, setChannel] = useState<'stable' | 'beta'>('stable');
|
||||
const [installing, setInstalling] = useState<Set<string>>(new Set());
|
||||
const [message, setMessage] = useFlash();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updates, setUpdates] = useState<UpdatesResult | null>(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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title={PAGE.label}
|
||||
description={PAGE.description}
|
||||
actions={(
|
||||
<>
|
||||
{channel === 'beta' && (
|
||||
<Badge tone="info" title="Pre-release versions are offered (extensionDirectoryChannel)">Beta channel</Badge>
|
||||
)}
|
||||
{directoryUrl && (
|
||||
<AdminButtonLink external href={directoryUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open directory
|
||||
</AdminButtonLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
{updates && noticeCount > 0 && (
|
||||
<UpdatesSection
|
||||
updates={updates}
|
||||
installing={installing}
|
||||
onUpdate={(u) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className={pillGroupClass}>
|
||||
{([['browse', 'Browse'], ['installed', `Installed${installedCount ? ` (${installedCount})` : ''}`]] as const).map(([v, label]) => (
|
||||
<button key={v} type="button" onClick={() => setView(v)} aria-pressed={view === v} className={pillClass(view === v)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'browse' ? 'Search extensions...' : 'Filter installed...'}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
aria-label={view === 'browse' ? 'Search extensions' : 'Filter installed'}
|
||||
className={cn(inputClass, 'w-full ps-9')}
|
||||
/>
|
||||
</div>
|
||||
{view === 'browse' && (
|
||||
<>
|
||||
<div className={cn(pillGroupClass, 'sm:self-auto')}>
|
||||
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => { setTypeFilter(t); setPage(1); }}
|
||||
aria-pressed={typeFilter === t}
|
||||
className={pillClass(typeFilter === t)}
|
||||
>
|
||||
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => { 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) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'installed' ? (
|
||||
<InstalledView
|
||||
updates={updates}
|
||||
loading={updatesLoading}
|
||||
filter={query}
|
||||
installing={installing}
|
||||
onUpdate={(entry, version) => install({ slug: entry.id, name: entry.name, type: entry.kind }, version, true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<EmptyState
|
||||
icon={Store}
|
||||
title={error}
|
||||
hint="The directory needs its v2 API (extensions.bulwarkmail.org has it; a self-hosted one must be from October 2026 or later)"
|
||||
action={<Button size="sm" onClick={fetchExtensions}>Retry</Button>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && extensions.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Store}
|
||||
title="No extensions found"
|
||||
hint={query ? 'Try a different search term' : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && extensions.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{total} extension{total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{extensions.map((ext) => (
|
||||
<ExtensionCard
|
||||
key={ext.slug}
|
||||
extension={ext}
|
||||
installing={installing.has(ext.slug)}
|
||||
onInstall={() => ext.latestVersion && install(ext, ext.latestVersion, ext.installed)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Updates and warnings for what is installed, from one directory request. */
|
||||
function UpdatesSection({
|
||||
updates,
|
||||
installing,
|
||||
onUpdate,
|
||||
onUpdateAll,
|
||||
}: {
|
||||
updates: UpdatesResult;
|
||||
installing: Set<string>;
|
||||
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 (
|
||||
<SettingsSection id="updates" title={title}>
|
||||
{updates.updates.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{updates.updates.length > 1 && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" className="gap-1.5" onClick={onUpdateAll} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
Update all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{updates.updates.map((u) => (
|
||||
<li key={u.slug} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<AdminLink href={detailHref(u.slug, u.version)} className="font-medium text-foreground hover:underline">{name(u.slug)}</AdminLink>
|
||||
<span className="text-muted-foreground">v{u.currentVersion} → v{u.version}</span>
|
||||
{u.channel === 'beta' && <Badge tone="info">beta</Badge>}
|
||||
</div>
|
||||
{u.changelog && <p className="mt-0.5 line-clamp-2 whitespace-pre-wrap text-xs text-muted-foreground">{u.changelog}</p>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1.5 self-start text-xs"
|
||||
onClick={() => onUpdate(u)}
|
||||
disabled={installing.has(u.slug)}
|
||||
>
|
||||
{installing.has(u.slug) ? <Loader2 className="h-3 w-3 animate-spin" /> : <ArrowUpCircle className="h-3 w-3" />}
|
||||
Update
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNotices && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{updates.advisories.map((a) => (
|
||||
<li key={a.id} className="flex items-start gap-2 text-destructive">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
<AdminLink href={detailHref(a.extension)} className="font-medium hover:underline">{name(a.extension)}</AdminLink>
|
||||
{' '}has a security advisory ({a.severity}): {a.summary}
|
||||
{a.fixedIn ? ` Fixed in v${a.fixedIn}.` : ' No fixed version yet.'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{updates.suspended.map((slug) => (
|
||||
<li key={slug} className="flex items-start gap-2 text-destructive">
|
||||
<Ban className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
<AdminLink href={detailHref(slug)} className="font-medium hover:underline">{name(slug)}</AdminLink>
|
||||
{' '}was suspended by the directory. Consider removing it.
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{updates.yanked.map((y) => (
|
||||
<li key={`${y.slug}@${y.version}`} className="flex items-start gap-2 text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
The installed v{y.version} of{' '}
|
||||
<AdminLink href={detailHref(y.slug)} className="font-medium hover:underline">{name(y.slug)}</AdminLink>
|
||||
{' '}was withdrawn by its publisher{y.reason ? `: ${y.reason}` : '.'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{updates.deprecated.map((d) => (
|
||||
<li key={d.slug} className="flex items-start gap-2 text-muted-foreground">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
<AdminLink href={detailHref(d.slug)} className="font-medium text-foreground hover:underline">{name(d.slug)}</AdminLink>
|
||||
{' '}is deprecated{d.message ? `: ${d.message}` : '.'}
|
||||
{d.replacement && <> Use <AdminLink href={detailHref(d.replacement)} className="underline">{d.replacement}</AdminLink> instead.</>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string>;
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState icon={Store} title={filter ? 'Nothing installed matches' : 'Nothing installed yet'} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{updates?.error && (
|
||||
<ConfigNote tone="warning">Could not check for updates: {updates.error}</ConfigNote>
|
||||
)}
|
||||
<AdminList>
|
||||
{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 (
|
||||
<AdminListRow
|
||||
key={`${entry.kind}:${entry.id}`}
|
||||
href={fromMarketplace ? detailHref(entry.id) : undefined}
|
||||
icon={entry.kind === 'plugin' ? <Puzzle /> : <SwatchBook />}
|
||||
title={entry.name}
|
||||
titleExtra={(
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">v{entry.version}</span>
|
||||
{!entry.enabled && <Badge>Off</Badge>}
|
||||
{entry.source?.kind === 'upload'
|
||||
? <Badge icon={<Upload className="h-3 w-3" />}>Uploaded</Badge>
|
||||
: <Badge icon={<Store className="h-3 w-3" />}>Marketplace</Badge>}
|
||||
{advisory && <Badge tone="danger" icon={<ShieldAlert className="h-3 w-3" />}>Advisory</Badge>}
|
||||
{yanked && <Badge tone="warning" icon={<AlertTriangle className="h-3 w-3" />}>Withdrawn</Badge>}
|
||||
</>
|
||||
)}
|
||||
control={(
|
||||
<>
|
||||
{newer && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => onUpdate(entry, newer)}
|
||||
disabled={installing.has(entry.id)}
|
||||
>
|
||||
{installing.has(entry.id) ? <Loader2 className="h-3 w-3 animate-spin" /> : <ArrowUpCircle className="h-3 w-3" />}
|
||||
Update to v{newer}
|
||||
</Button>
|
||||
)}
|
||||
<AdminButtonLink href={manageHref} className="h-8 text-xs">Manage</AdminButtonLink>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AdminList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rating({ rating }: { rating: { average: number; count: number } }) {
|
||||
if (!rating.count) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1" title={`${rating.average} out of 5 from ${rating.count} rating${rating.count === 1 ? '' : 's'}`}>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{rating.average.toFixed(1)}
|
||||
<span className="opacity-70">({rating.count})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
{extension.bannerUrl && (
|
||||
<AdminLink href={previewHref} className="block focus:outline-none">
|
||||
<img
|
||||
src={extension.bannerUrl}
|
||||
alt=""
|
||||
className="block h-24 w-full object-cover border-b border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</AdminLink>
|
||||
)}
|
||||
<AdminLink href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{extension.iconUrl ? (
|
||||
<img
|
||||
src={extension.iconUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||
{extension.name}
|
||||
</span>
|
||||
{extension.featured && (
|
||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<Badge tone={isPlugin ? 'info' : 'accent'}>
|
||||
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
|
||||
</Badge>
|
||||
{offered?.channel === 'beta' && <Badge tone="info">beta</Badge>}
|
||||
{extension.deprecated && <Badge>deprecated</Badge>}
|
||||
{extension.author && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
by {extension.author.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||
{extension.description}
|
||||
</p>
|
||||
|
||||
{extension.tags && extension.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{extension.tags.slice(0, 3).map(tag => (
|
||||
<Badge key={tag}>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{extension.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
<Rating rating={extension.rating} />
|
||||
{extension.permissions && extension.permissions.length > 0 && (
|
||||
<span title={extension.permissions.join(', ')}>
|
||||
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||
<Eye className="w-3 h-3" />
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
</AdminLink>
|
||||
|
||||
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||
{extension.installed && updateAvailable && offered ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
title={`Update from v${extension.installedVersion} to v${offered.version}`}
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
)}
|
||||
Update to v{offered.version}
|
||||
</Button>
|
||||
) : extension.installed ? (
|
||||
<Badge
|
||||
tone="success"
|
||||
icon={<Check className="w-3 h-3" />}
|
||||
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
|
||||
>
|
||||
Installed{extension.installedVersion ? ` v${extension.installedVersion}` : ''}
|
||||
</Badge>
|
||||
) : !offered ? (
|
||||
<Badge
|
||||
tone="warning"
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
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}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
title={`Install v${offered.version}`}
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3 h-3" />
|
||||
)}
|
||||
Quick install
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ApprovalEntry['status'], BadgeTone> = {
|
||||
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<ApprovalEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<SourceView | null>(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 (
|
||||
<div key={key} className="space-y-2 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{entry.manifest.name ?? entry.pluginId}</span>
|
||||
<span className="text-xs text-muted-foreground">v{entry.manifest.version}</span>
|
||||
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
|
||||
</div>
|
||||
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<code className="font-mono">{entry.pluginId}</code> · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
|
||||
</div>
|
||||
{perms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{perms.map((p) => (
|
||||
<Badge key={p} tone={HIGH_RISK_PERMISSIONS.has(p) ? 'warning' : 'muted'} className="font-mono">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sendsTo.length > 0 && (
|
||||
<div className="break-all text-xs text-muted-foreground">Can send data to: {sendsTo.join(', ')}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => viewSource(entry)}>
|
||||
<Code className="h-3.5 w-3.5" /> View code
|
||||
</Button>
|
||||
{entry.status !== 'approved' && (
|
||||
<Button type="button" size="sm" className="h-8 gap-1.5 text-xs" disabled={busy === key} onClick={() => decide(entry, 'approved')}>
|
||||
<Check className="h-3.5 w-3.5" /> Approve
|
||||
</Button>
|
||||
)}
|
||||
{entry.status !== 'denied' && (
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" disabled={busy === key} onClick={() => decide(entry, 'denied')}>
|
||||
<X className="h-3.5 w-3.5" /> Deny
|
||||
</Button>
|
||||
)}
|
||||
{entry.status !== 'pending' && (
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8 text-xs text-muted-foreground hover:text-destructive" disabled={busy === key} onClick={() => decide(entry, 'revoke')}>
|
||||
Forget decision
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="approvals"
|
||||
title={pending.length > 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 && <p className="text-sm text-destructive">{error}</p>}
|
||||
{pending.length > 0 && (
|
||||
<div className="divide-y divide-border rounded-lg border border-amber-300/60 dark:border-amber-700/60">
|
||||
{pending.map(renderEntry)}
|
||||
</div>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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'}
|
||||
</button>
|
||||
{showPast && (
|
||||
<div className="mt-2 divide-y divide-border rounded-lg border border-border">
|
||||
{past.map(renderEntry)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onMouseDown={(e) => { if (e.target === e.currentTarget) setSource(null); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Plugin code" className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium">{source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}</span>
|
||||
<button type="button" onClick={() => setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
{source.warnings && source.warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 border-b border-border px-4 py-2 text-xs text-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 text-warning" /> {source.warnings.join('; ')}
|
||||
</div>
|
||||
)}
|
||||
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-all p-4 font-mono text-[11px] leading-relaxed">
|
||||
{source.error ?? source.code ?? (<span className="inline-flex items-center gap-2"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading</span>)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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<ExtensionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...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<HTMLInputElement>) {
|
||||
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<boolean> {
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title="Plugins"
|
||||
description="Plugins installed for every user of this Bulwark. Open one to configure it."
|
||||
actions={(
|
||||
<>
|
||||
<AdminButtonLink href={adminPath('marketplace')}>
|
||||
<Store className="h-4 w-4" />
|
||||
Marketplace
|
||||
</AdminButtonLink>
|
||||
<label className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Upload plugin
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<PluginApprovalsPanel />
|
||||
|
||||
<SettingsSection
|
||||
id="installed"
|
||||
title="Installed plugins"
|
||||
description={loading || plugins.length === 0 ? undefined : `${enabledCount} of ${plugins.length} enabled. The switch turns a plugin on or off for everyone.`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title="No plugins installed"
|
||||
hint="Install one from the marketplace or upload a plugin ZIP."
|
||||
action={<AdminButtonLink href={adminPath('marketplace')}><Store className="h-4 w-4" /> Browse the marketplace</AdminButtonLink>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{plugins.length > 1 && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === plugins.length} onClick={() => setAll(true)}>
|
||||
<Power className="h-3.5 w-3.5" /> Enable all
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
|
||||
<PowerOff className="h-3.5 w-3.5" /> Disable all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AdminList>
|
||||
{plugins.map((plugin) => {
|
||||
const perms = plugin.permissions ?? [];
|
||||
const risky = perms.filter((p) => HIGH_RISK_PERMISSIONS.has(p));
|
||||
return (
|
||||
<AdminListRow
|
||||
key={plugin.id}
|
||||
data-testid={`admin-plugin-${plugin.id}`}
|
||||
href={adminPath('plugins', plugin.id)}
|
||||
icon={<ExtensionIcon url={iconFor(plugin)} fallback={Puzzle} />}
|
||||
title={plugin.name}
|
||||
titleExtra={(
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
{!plugin.enabled && <Badge>Off</Badge>}
|
||||
{plugin.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
|
||||
{plugin.dev && <Badge tone="info" title="Loaded from the dev plugins directory">Dev</Badge>}
|
||||
{risky.length > 0 && (
|
||||
<Badge tone="warning" icon={<ShieldAlert className="h-3 w-3" />} title={risky.join(', ')}>
|
||||
{risky.length} high-risk permission{risky.length === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
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={(
|
||||
<ToggleSwitch
|
||||
checked={plugin.enabled}
|
||||
onChange={(checked) => { setMessage(null); void setEnabled(plugin.id, checked); }}
|
||||
ariaLabel={`${plugin.name} enabled`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AdminList>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="policy" title="Plugin policy" description="What users may do with plugins on their own.">
|
||||
<SettingItem label="Plugins" description="Load and run plugins for users at all. Off hides the plugin system everywhere.">
|
||||
<ToggleSwitch checked={pluginsEnabled} onChange={(v) => setFeature('pluginsEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Users may upload plugins" description="Let users install plugin ZIPs for their own account from their settings.">
|
||||
<ToggleSwitch checked={pluginsUploadEnabled} disabled={!pluginsEnabled} onChange={(v) => setFeature('pluginsUploadEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Require approval"
|
||||
description="A plugin a user uploads stays off until an admin has approved that exact package here."
|
||||
>
|
||||
<ToggleSwitch checked={requirePluginApproval} disabled={!pluginsEnabled || !pluginsUploadEnabled} onChange={(v) => setFeature('requirePluginApproval', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={policyDirty}
|
||||
saving={savingPolicy}
|
||||
onSave={savePolicy}
|
||||
onDiscard={() => setPolicy(savedPolicy)}
|
||||
saveLabel="Save policy"
|
||||
note="Users see the change on their next login"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
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<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...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<PushRelayOption>) {
|
||||
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<AdminSidebarApp>) {
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
<SettingsSection
|
||||
id="features"
|
||||
title="Features"
|
||||
description="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;
|
||||
return (
|
||||
<SettingItem key={key} label={label} description={description}>
|
||||
<ToggleSwitch checked={!!policy.features[key]} onChange={() => toggleFeature(key)} />
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="push-relays"
|
||||
title="Push relays"
|
||||
description="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."
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Relays</span>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Mark one as the default. Relays without a valid URL are not offered to users.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 shrink-0 gap-1.5 text-xs" onClick={addPushRelay}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add relay
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={cardClass}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">Bulwark relay</span>
|
||||
<Badge>built-in</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{DEFAULT_RELAY_BASE_URL}</p>
|
||||
</div>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="default-push-relay"
|
||||
checked={defaultRelayUrl === normalizeRelayUrl(DEFAULT_RELAY_BASE_URL)}
|
||||
onChange={() => setPushRelayUrl('')}
|
||||
className="border-input"
|
||||
/>
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(policy.pushRelays ?? []).map((relay, index) => (
|
||||
<div key={index} className={cn(cardClass, 'space-y-2')}>
|
||||
<div className="grid grid-cols-1 items-start gap-2 sm:grid-cols-12">
|
||||
<div className="sm:col-span-4">
|
||||
<label className={fieldLabelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={relay.label}
|
||||
onChange={(e) => updatePushRelay(index, { label: e.target.value })}
|
||||
placeholder="Company relay"
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-8">
|
||||
<label className={fieldLabelClass}>Relay URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={relay.url}
|
||||
onChange={(e) => updatePushRelay(index, { url: e.target.value })}
|
||||
placeholder="https://notifications.relay.example.com"
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex w-fit cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="default-push-relay"
|
||||
checked={isValidRelayUrl(relay.url) && defaultRelayUrl === normalizeRelayUrl(relay.url)}
|
||||
disabled={!isValidRelayUrl(relay.url)}
|
||||
onChange={() => setPushRelayUrl(normalizeRelayUrl(relay.url))}
|
||||
className="border-input disabled:opacity-40"
|
||||
/>
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<SettingItem label="Pin users to the default relay" description="Lock the relay choice: users cannot pick another relay in their notification settings.">
|
||||
<ToggleSwitch checked={!!policy.pushRelayUrlLocked} onChange={togglePushRelayLocked} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="sidebar-apps"
|
||||
title="Default sidebar apps"
|
||||
description="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."
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Apps</span>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1.5 text-xs"
|
||||
onClick={addDefaultApp}
|
||||
disabled={defaultSidebarApps.length >= MAX_DEFAULT_SIDEBAR_APPS}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add app
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{defaultSidebarApps.length === 0 && (
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
No default apps. Users only see the apps they add themselves.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{defaultSidebarApps.map((app, index) => {
|
||||
const AppIcon = iconForName(app.icon);
|
||||
const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url);
|
||||
return (
|
||||
<div key={app.id} className={cn(cardClass, 'space-y-2')}>
|
||||
<div className="grid grid-cols-1 items-start gap-2 sm:grid-cols-12">
|
||||
<div className="sm:col-span-4">
|
||||
<label className={fieldLabelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={50}
|
||||
value={app.name}
|
||||
onChange={(e) => updateDefaultApp(index, { name: e.target.value })}
|
||||
placeholder="Intranet"
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className={fieldLabelClass}>URL</label>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={2048}
|
||||
value={app.url}
|
||||
onChange={(e) => updateDefaultApp(index, { url: e.target.value })}
|
||||
placeholder="https://intranet.example.com"
|
||||
className={cn(inputClass, 'w-full', urlInvalid && 'border-destructive')}
|
||||
/>
|
||||
{urlInvalid && (
|
||||
<p className="mt-1 text-[11px] text-destructive">Enter a valid http or https URL</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className={fieldLabelClass}>
|
||||
Icon (
|
||||
<a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Tabler</a>
|
||||
)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
value={app.icon}
|
||||
onChange={(e) => updateDefaultApp(index, { icon: e.target.value })}
|
||||
placeholder="world"
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground"
|
||||
title={AppIcon ? app.icon : 'Unknown icon - falls back to Globe'}
|
||||
>
|
||||
{AppIcon ? <AppIcon className="h-4 w-4" /> : <span className="text-[10px]">?</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name={`default-app-mode-${app.id}`}
|
||||
checked={app.openMode !== 'inline'}
|
||||
onChange={() => updateDefaultApp(index, { openMode: 'tab' })}
|
||||
className="border-input"
|
||||
/>
|
||||
New tab
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name={`default-app-mode-${app.id}`}
|
||||
checked={app.openMode === 'inline'}
|
||||
onChange={() => updateDefaultApp(index, { openMode: 'inline' })}
|
||||
className="border-input"
|
||||
/>
|
||||
Inline
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.showOnMobile}
|
||||
onChange={() => updateDefaultApp(index, { showOnMobile: !app.showOnMobile })}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
Show on mobile
|
||||
</label>
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDefaultApp(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
title="Move up"
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDefaultApp(index, 1)}
|
||||
disabled={index === defaultSidebarApps.length - 1}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
title="Move down"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDefaultApp(index)}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
|
||||
title="Remove app"
|
||||
aria-label="Remove app"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="restrictions"
|
||||
title="Locked settings"
|
||||
description="Lock a setting so users cannot change it, or hide it from their settings entirely."
|
||||
>
|
||||
{categories.map(category => (
|
||||
<div key={category}>
|
||||
<p className={subLabelClass}>{category}</p>
|
||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||
const restriction = policy.restrictions[setting.key] || {};
|
||||
return (
|
||||
<SettingItem key={setting.key} label={setting.label}>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||
className="rounded border-input" />
|
||||
<Lock className="h-3 w-3" /> Lock
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||
className="rounded border-input" />
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onDiscard={discard}
|
||||
saveLabel="Save policy"
|
||||
note="Users see the change on their next login"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const jmapServers = currentValue('jmapServers');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
<SettingsSection id="general" title="General">
|
||||
<ConfigTextItem label="Application name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigTextItem label="JMAP server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
|
||||
<ConfigToggleItem label="Allow custom JMAP endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<ConfigNote tone="warning">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</ConfigNote>
|
||||
)}
|
||||
<ConfigToggleItem label="Stalwart features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Demo mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Search engine indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="login" title="Login page">
|
||||
<ConfigToggleItem label="Show version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show 2FA code option" description="Show the "I have a 2FA code" toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show access token sign-in" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show remember me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="servers" title="JMAP servers">
|
||||
<ConfigToggleItem
|
||||
label="Auto-pick server by email domain"
|
||||
description="When users type their email, automatically select the matching server from the list below."
|
||||
configKey="jmapServerAutoPickByDomain"
|
||||
value={currentValue('jmapServerAutoPickByDomain') as boolean}
|
||||
source={config.jmapServerAutoPickByDomain?.source}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
<JmapServersSection
|
||||
value={(jmapServers as JmapServerEntry[]) ?? []}
|
||||
source={config.jmapServers?.source}
|
||||
onChange={(next) => handleChange('jmapServers', next)}
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(jmapServers) && (jmapServers as JmapServerEntry[]).length > 0 && (
|
||||
<ConfigNote tone="warning">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</ConfigNote>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="wopi" title="Document editing">
|
||||
<ConfigTextItem label="WOPI client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" wide />
|
||||
<ConfigTextItem label="WOPI host URL override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" wide />
|
||||
<ConfigNote>
|
||||
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.
|
||||
</ConfigNote>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="logging" title="Logging">
|
||||
<ConfigSelectItem label="Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigSelectItem label="Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="sync" title="Settings sync">
|
||||
<ConfigToggleItem label="Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={hasEdits}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onDiscard={() => setEdits({})}
|
||||
note="Takes effect on the next page load"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<TelemetryStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [endpointDraft, setEndpointDraft] = useState('');
|
||||
const [message, setMessage] = useFlash();
|
||||
|
||||
const endpointDirty = status !== null && endpointDraft !== status.endpoint;
|
||||
useAdminUnsavedChanges('telemetry', endpointDirty);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 = <AdminPageHeader title={PAGE.label} description={PAGE.description} />;
|
||||
|
||||
if (loading || !status) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{header}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<ConfigNote>
|
||||
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 <strong>off by
|
||||
default</strong>; one click below enables it and helps us make the product better. No
|
||||
email addresses, no hostnames, no IPs are sent.{' '}
|
||||
<a
|
||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</ConfigNote>
|
||||
|
||||
<SettingsSection id="status" title="Status">
|
||||
<SettingItem label="Send anonymous heartbeats" description={consentDescription}>
|
||||
<ToggleSwitch
|
||||
checked={isOn}
|
||||
disabled={busy === 'consent' || envOverridden}
|
||||
onChange={(checked) => void setConsent(checked ? 'on' : 'off')}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last sent">
|
||||
<span className="text-sm text-foreground">{timeAgo(status.lastSentAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Next scheduled">
|
||||
<span className="text-sm text-foreground">{timeAgo(status.nextScheduledAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Consented at">
|
||||
<span className="text-sm text-foreground">{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="accounts"
|
||||
title="Account activity"
|
||||
description="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."
|
||||
>
|
||||
<SettingItem label="Total (90 days)">
|
||||
<span className="text-sm font-mono text-foreground">{status.accountCounts?.total ?? 0}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Active (7 days)">
|
||||
<span className="text-sm font-mono text-foreground">{status.accountCounts?.active7d ?? 0}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="endpoint"
|
||||
title="Endpoint"
|
||||
description="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."
|
||||
>
|
||||
<SettingItem label="Collector URL">
|
||||
<input
|
||||
type="url"
|
||||
value={endpointDraft}
|
||||
onChange={(e) => { 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')}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="payload"
|
||||
title="Payload preview"
|
||||
description="Exactly what the next heartbeat would send from this install, right now."
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={busy === 'send' || !isOn}
|
||||
onClick={() => void sendNow()}
|
||||
>
|
||||
{busy === 'send' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Send now
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||
</pre>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={endpointDirty}
|
||||
saving={busy === 'endpoint'}
|
||||
onSave={() => void saveEndpoint()}
|
||||
onDiscard={() => setEndpointDraft(status.endpoint)}
|
||||
saveLabel="Save endpoint"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ExtensionEntry[]>([]);
|
||||
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<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
|
||||
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>(() => 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<Pick<ThemePolicy, 'defaultColorMode' | 'lockColorMode'>>) {
|
||||
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<HTMLInputElement>) {
|
||||
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<boolean> {
|
||||
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 (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
title={PAGE.label}
|
||||
description={PAGE.description}
|
||||
actions={(
|
||||
<>
|
||||
<AdminButtonLink href={adminPath('marketplace')}>
|
||||
<Store className="h-4 w-4" />
|
||||
Marketplace
|
||||
</AdminButtonLink>
|
||||
<label className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Upload theme
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SettingsSection
|
||||
id="installed"
|
||||
title="Installed themes"
|
||||
description={loading || themes.length === 0 ? undefined : `${enabledCount} of ${themes.length} enabled. The switch turns a theme on or off for everyone.`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
) : themes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SwatchBook}
|
||||
title="No themes installed"
|
||||
hint="Install one from the marketplace or upload a theme ZIP."
|
||||
action={<AdminButtonLink href={adminPath('marketplace')}><Store className="h-4 w-4" /> Browse the marketplace</AdminButtonLink>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{themes.length > 1 && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === themes.length} onClick={() => setAll(true)}>
|
||||
<Power className="h-3.5 w-3.5" /> Enable all
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
|
||||
<PowerOff className="h-3.5 w-3.5" /> Disable all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AdminList>
|
||||
{themes.map(theme => (
|
||||
<AdminListRow
|
||||
key={theme.id}
|
||||
data-testid={`admin-theme-${theme.id}`}
|
||||
href={adminPath('themes', theme.id)}
|
||||
icon={<ExtensionIcon url={iconFor(theme)} fallback={SwatchBook} />}
|
||||
title={theme.name}
|
||||
titleExtra={(
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||
{!theme.enabled && <Badge>Off</Badge>}
|
||||
{theme.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
|
||||
</>
|
||||
)}
|
||||
description={theme.description}
|
||||
meta={(
|
||||
<>
|
||||
by {theme.author || 'unknown'}
|
||||
{theme.variants && theme.variants.length > 0 && <> · {theme.variants.join(', ')}</>}
|
||||
{theme.source?.kind === 'marketplace' && <> · Marketplace</>}
|
||||
</>
|
||||
)}
|
||||
control={(
|
||||
<ToggleSwitch
|
||||
checked={theme.enabled}
|
||||
onChange={(checked) => { setMessage(null); void setEnabled(theme.id, checked); }}
|
||||
ariaLabel={`${theme.name} enabled`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</AdminList>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="policy" title="Theme policy" description="Control theme availability and defaults for users.">
|
||||
<SettingItem label="Themes" description="Allow users to select and apply themes">
|
||||
<ToggleSwitch checked={themesEnabled} onChange={(v) => setFeature('themesEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Users may upload themes" description="Allow users to upload their own theme files">
|
||||
<ToggleSwitch checked={userThemesEnabled} disabled={!themesEnabled} onChange={(v) => setFeature('userThemesEnabled', v)} />
|
||||
</SettingItem>
|
||||
<SettingItem label="Default theme" description="Theme applied when users have not chosen one">
|
||||
<select
|
||||
value={policy.themePolicy?.defaultThemeId || ''}
|
||||
onChange={(e) => setDefaultTheme(e.target.value || null)}
|
||||
aria-label="Default theme"
|
||||
className={cn(inputClass, 'w-full pe-8 cursor-pointer sm:w-auto')}
|
||||
>
|
||||
<option value="">System default</option>
|
||||
<optgroup label="Built-in">
|
||||
{BUILTIN_THEME_OPTIONS
|
||||
.filter(t => !disabledBuiltin.includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{themes.length > 0 && (
|
||||
<optgroup label="Installed">
|
||||
{themes
|
||||
.filter(t => !disabledInstalled.includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</SettingItem>
|
||||
<SettingItem label="Default colour mode" description="Light, dark or system for users who have not picked one">
|
||||
<select
|
||||
value={policy.themePolicy?.defaultColorMode || 'system'}
|
||||
onChange={(e) => 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')}
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</SettingItem>
|
||||
<SettingItem label="Lock colour mode" description="Everyone gets the default colour mode and cannot switch it in settings, on the login page or in the command palette">
|
||||
<ToggleSwitch checked={lockColorMode} onChange={(v) => setColorModePolicy({ lockColorMode: v })} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="offered" title="Offered to users" description="Themes users can pick. Turn one off to hide it from the picker.">
|
||||
{BUILTIN_THEME_OPTIONS.map(theme => (
|
||||
<SettingItem key={theme.id} label={theme.name}>
|
||||
<ToggleSwitch checked={!disabledBuiltin.includes(theme.id)} onChange={() => toggleBuiltinTheme(theme.id)} />
|
||||
</SettingItem>
|
||||
))}
|
||||
{themes.length > 0 && (
|
||||
<>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Installed</p>
|
||||
{themes.map(theme => (
|
||||
<SettingItem key={theme.id} label={theme.name}>
|
||||
<ToggleSwitch checked={!disabledInstalled.includes(theme.id)} onChange={() => toggleAdminTheme(theme.id)} />
|
||||
</SettingItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={policyDirty}
|
||||
saving={savingPolicy}
|
||||
onSave={savePolicy}
|
||||
onDiscard={() => setPolicy(savedPolicy)}
|
||||
saveLabel="Save policy"
|
||||
note="Users see the change on their next login"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> | null = null;
|
||||
let pending: Promise<Map<string, string>> | null = null;
|
||||
|
||||
async function loadIcons(): Promise<Map<string, string>> {
|
||||
const icons = new Map<string, string>();
|
||||
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<ExtensionEntry, 'id' | 'source'>) => string | null {
|
||||
const [icons, setIcons] = useState<Map<string, string> | null>(cache);
|
||||
useEffect(() => {
|
||||
if (cache) return;
|
||||
let cancelled = false;
|
||||
pending ??= loadIcons().then((map) => { cache = map; return map; }).catch(() => new Map<string, string>());
|
||||
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;
|
||||
}
|
||||
@@ -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<VersionAdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
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<void> {
|
||||
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 = (
|
||||
<AdminPageHeader
|
||||
title={PAGE.label}
|
||||
description={PAGE.description}
|
||||
actions={(
|
||||
<Button type="button" size="sm" className="gap-2" disabled={checking || loading} onClick={() => void checkNow()}>
|
||||
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Check now
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{header}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SettingsSection id="status" title="Status">
|
||||
<SettingItem label="Severity">
|
||||
<Badge tone={chip.tone} icon={<ChipIcon className="h-3 w-3" />}>{chip.label}</Badge>
|
||||
</SettingItem>
|
||||
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
|
||||
<span className="text-sm font-mono text-foreground">{data.current}</span>
|
||||
</SettingItem>
|
||||
{newer && (
|
||||
<SettingItem label="Latest release">
|
||||
{releaseUrl ? (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
|
||||
>
|
||||
{newer} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-mono text-foreground">{newer}</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{status?.advisory && (
|
||||
<SettingItem label="Advisory">
|
||||
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="schedule" title="Schedule" description="Hourly polling with ±5 minute jitter.">
|
||||
<SettingItem label="Last checked">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last success">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Next scheduled">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
|
||||
</SettingItem>
|
||||
{status?.checkedAt && (
|
||||
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
|
||||
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="source"
|
||||
title="Source"
|
||||
description="Hourly check against the Bulwark version server. Severity is decided server-side and disable with BULWARK_UPDATE_CHECK=off."
|
||||
>
|
||||
<SettingItem
|
||||
label="Endpoint"
|
||||
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
|
||||
>
|
||||
<a
|
||||
href={data.endpoint}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
|
||||
>
|
||||
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</SettingItem>
|
||||
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
|
||||
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
|
||||
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openSetupDialog}
|
||||
disabled={setupRunning}
|
||||
className="shrink-0 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"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-setup-title"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-border">
|
||||
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||
Webmail origin
|
||||
</label>
|
||||
<input
|
||||
id="setup-origin"
|
||||
type="url"
|
||||
value={setupOrigin}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback</code>) on Stalwart.
|
||||
</p>
|
||||
{!setupOriginValid && setupOrigin.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
|
||||
Stalwart issuer URL
|
||||
</label>
|
||||
<input
|
||||
id="setup-issuer"
|
||||
type="url"
|
||||
value={setupIssuer}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
|
||||
</p>
|
||||
{!setupIssuerValid && setupIssuer.length > 0 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupOauthOnly}
|
||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-input"
|
||||
disabled={setupRunning}
|
||||
/>
|
||||
Also enable “OAuth only” (hide password login)
|
||||
</label>
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoSetup}
|
||||
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
|
||||
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"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Configure'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
<Toggle label="Allow private OAuth endpoints" description="Permit discovered endpoints on other hosts to resolve to RFC-1918 / loopback addresses. Endpoints on the issuer's own host are always allowed, so split-DNS setups rarely need this." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Scopes" description={`Space-separated scopes that replace the defaults (${DEFAULT_OAUTH_SCOPES}). Leave blank to use them. offline_access is not requested by default; add it here or under Extra Scopes if your provider only issues refresh tokens for it, or sessions end when the access token expires.`} configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder={DEFAULT_OAUTH_SCOPES} />
|
||||
<Text label="OAuth Extra Scopes" description={`Additional space-separated scopes appended to the defaults (${DEFAULT_OAUTH_SCOPES}). Ignored when OAuth Scopes is set.`} configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
<Section title="Single Sign-On">
|
||||
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="End provider session on sign-out" description="Signing out of an SSO account also signs out of the identity provider, when it advertises an end_session_endpoint. Turn off if the provider is shared with apps that should stay signed in." configKey="oauthEndSession" value={currentValue('oauthEndSession') as boolean} source={config.oauthEndSession?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Post-logout redirect URI" description="Where the provider sends users after signing them out. Register it with the provider as a post-logout redirect URI first, or it will refuse the logout. Leave blank to stay on the provider's signed-out page." configKey="oauthPostLogoutRedirectUri" value={currentValue('oauthPostLogoutRedirectUri') as string} source={config.oauthPostLogoutRedirectUri?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/en/login" />
|
||||
</Section>
|
||||
|
||||
<Section title="Admin Dashboard">
|
||||
<Select
|
||||
label="Stalwart admin access"
|
||||
description="What a Stalwart admin account grants in this dashboard. “Automatic” signs Stalwart admins in without the admin password; “Password required” keeps the shield but asks for the admin password; “Off” ignores Stalwart admin status entirely. The last two need an admin password to be configured."
|
||||
configKey="stalwartAdminAccess"
|
||||
value={currentValue('stalwartAdminAccess') as string}
|
||||
source={config.stalwartAdminAccess?.source}
|
||||
options={['auto', 'password', 'off']}
|
||||
optionLabels={{ auto: 'Automatic (no password)', password: 'Password required', off: 'Off (separate admins)' }}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Session & Security">
|
||||
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => 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' && (
|
||||
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button type="button" role="switch" aria-checked={value} aria-label={label}
|
||||
onClick={() => 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'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select value={value ?? ''} onChange={(e) => 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 => <option key={o} value={o}>{optionLabels?.[o] ?? o}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||
() => 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<string, string>): 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<string, string | undefined>;
|
||||
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope picker */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Scope</h2>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
{domainEntries.map(entry => (
|
||||
<button
|
||||
key={entry.host}
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
{!addingHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add domain
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addingHost && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newHostInput}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddDomain}
|
||||
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||
Unset fields fall back to the Default values.
|
||||
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDomain}
|
||||
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Remove domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{PWA_IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{PWA_TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => 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) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => 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) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-warning">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<SettingsSection title="Server" description="Application and connection details">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last Login">
|
||||
<span className="text-sm text-foreground">
|
||||
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Features" description="Enabled integrations and modules">
|
||||
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
|
||||
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
|
||||
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="OAuth" description="OAuth authentication provider">
|
||||
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
|
||||
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||
<SettingItem label="Plugins">
|
||||
<span className="text-sm text-foreground">{pluginCount}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Themes">
|
||||
<span className="text-sm text-foreground">{themeCount}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Policy Rules">
|
||||
<span className="text-sm text-foreground">{policyRuleCount}</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Recent Activity" description="Latest administrative actions">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="py-4 text-sm text-muted-foreground">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((entry, i) => (
|
||||
<SettingItem
|
||||
key={i}
|
||||
label={entry.action}
|
||||
description={formatDetail(entry.detail) || undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{entry.ip}</span>
|
||||
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
))
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): 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);
|
||||
}
|
||||
@@ -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<AuditEntry[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { 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"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<option value="admin.login">Login</option>
|
||||
<option value="admin.logout">Logout</option>
|
||||
<option value="admin.login_failed">Login Failed</option>
|
||||
<option value="admin.login_blocked">Login Blocked</option>
|
||||
<option value="admin.change-password">Password Change</option>
|
||||
<option value="config.update">Config Update</option>
|
||||
<option value="config.revert">Config Revert</option>
|
||||
<option value="policy.update">Policy Update</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="sm:hidden space-y-2">
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-foreground break-words">
|
||||
{formatDetail(entry.detail)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||
</tr>
|
||||
) : entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<tr key={i} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||
{formatDetail(entry.detail)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): 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<string, unknown>) => `${c.key}`).join(', ');
|
||||
}
|
||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||
return JSON.stringify(detail).slice(0, 100);
|
||||
}
|
||||
@@ -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<Extension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [perPage] = useState(12);
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse and install plugins and themes from the BulwarkMail extension directory
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search extensions..."
|
||||
value={searchInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
|
||||
{(['all', 'plugin', 'theme'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { 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'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-border rounded-lg p-12 text-center">
|
||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Start the extension directory server on the configured port
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchExtensions}
|
||||
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && extensions.length === 0 && (
|
||||
<div className="border border-border rounded-lg p-12 text-center">
|
||||
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No extensions found</p>
|
||||
{query && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Try a different search term
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && extensions.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{total} extension{total !== 1 ? 's' : ''} found
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{extensions.map((ext) => (
|
||||
<ExtensionCard
|
||||
key={ext.slug}
|
||||
extension={ext}
|
||||
installing={installing === ext.slug}
|
||||
onInstall={() => handleInstall(ext)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
{extension.bannerUrl && (
|
||||
<Link href={previewHref} className="block focus:outline-none">
|
||||
<img
|
||||
src={extension.bannerUrl}
|
||||
alt=""
|
||||
className="block h-24 w-full object-cover border-b border-border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{extension.iconUrl ? (
|
||||
<img
|
||||
src={extension.iconUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground truncate group-hover:underline">
|
||||
{extension.name}
|
||||
</span>
|
||||
{extension.featured && (
|
||||
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
isPlugin
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||
}`}>
|
||||
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
|
||||
</span>
|
||||
{extension.author && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
by {extension.author.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
|
||||
{extension.description}
|
||||
</p>
|
||||
|
||||
{extension.tags && extension.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{extension.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{extension.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
{extension.permissions && extension.permissions.length > 0 && (
|
||||
<span title={extension.permissions.join(', ')}>
|
||||
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||
<Eye className="w-3 h-3" />
|
||||
Preview
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||
{extension.installed && updateAvailable ? (
|
||||
<button
|
||||
onClick={(e) => { 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 ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
)}
|
||||
Update to v{extension.latestVersion}
|
||||
</button>
|
||||
) : extension.installed ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
|
||||
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
Installed
|
||||
</span>
|
||||
) : versionMismatch ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
|
||||
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Requires v{extension.minAppVersion}+
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { 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 ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3 h-3" />
|
||||
)}
|
||||
Quick install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ApprovalEntry['status'], string> = {
|
||||
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<ApprovalEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<SourceView | null>(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 (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Approval Requests</h2>
|
||||
{pending > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400">{pending} waiting</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-4 py-2 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{entries === null ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground"><Loader2 className="inline w-4 h-4 animate-spin" /></div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">No requests yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry) => {
|
||||
const key = `${entry.pluginId}:${entry.bundleHash}`;
|
||||
const perms = entry.manifest.permissions ?? [];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{entry.manifest.name ?? entry.pluginId}</span>
|
||||
<span className="text-xs text-muted-foreground">v{entry.manifest.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${STATUS_STYLE[entry.status]}`}>{entry.status}</span>
|
||||
</div>
|
||||
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
|
||||
</div>
|
||||
{perms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{perms.map((p) => (
|
||||
<span key={p} className={`text-[11px] px-1.5 py-0.5 rounded font-mono ${HIGH_RISK_PERMISSIONS.has(p) ? 'bg-warning/15 text-foreground' : 'bg-muted text-muted-foreground'}`}>{p}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && (
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<button type="button" onClick={() => viewSource(entry)} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs hover:bg-accent">
|
||||
<Code className="w-3.5 h-3.5" /> View code
|
||||
</button>
|
||||
{entry.status !== 'approved' && (
|
||||
<button type="button" disabled={busy === key} onClick={() => 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">
|
||||
<Check className="w-3.5 h-3.5" /> Approve
|
||||
</button>
|
||||
)}
|
||||
{entry.status !== 'denied' && (
|
||||
<button type="button" disabled={busy === key} onClick={() => 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">
|
||||
<X className="w-3.5 h-3.5" /> Deny
|
||||
</button>
|
||||
)}
|
||||
{entry.status !== 'pending' && (
|
||||
<button type="button" disabled={busy === key} onClick={() => 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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onMouseDown={(e) => { if (e.target === e.currentTarget) setSource(null); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Plugin code" className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-background shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium">{source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}</span>
|
||||
<button type="button" onClick={() => setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
{source.warnings && source.warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 border-b border-border px-4 py-2 text-xs text-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 text-warning" /> {source.warnings.join('; ')}
|
||||
</div>
|
||||
)}
|
||||
<pre className="min-h-0 flex-1 overflow-auto p-4 text-[11px] leading-relaxed font-mono whitespace-pre-wrap break-all">
|
||||
{source.error ?? source.code ?? 'Loading…'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, ConfigField>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||
const [config, setConfig] = useState<PluginConfig>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||
</button>
|
||||
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = plugin.configSchema;
|
||||
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-label="Back to Plugins"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5" />
|
||||
{plugin.name} Configuration
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
v{plugin.version} by {plugin.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSchema ? (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Settings</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-5">
|
||||
{Object.entries(schema).map(([key, field]) => (
|
||||
<div key={key}>
|
||||
<label className="text-sm font-medium text-foreground block mb-1">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ms-0.5">*</span>}
|
||||
</label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' ? (
|
||||
<select
|
||||
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
) : field.type === 'select' && field.options ? (
|
||||
<select
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="">- Select -</option>
|
||||
{field.options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'secret' ? (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={revealSecrets[key] ? 'text' : 'password'}
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleSaveAll}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginAdminSection pluginId={pluginId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Plugin admin panel
|
||||
</div>
|
||||
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PluginEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
const [configuringId, setConfiguringId] = useState<string | null>(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<HTMLInputElement>) {
|
||||
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<string, unknown> = { 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 <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
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"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label 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 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Plugin
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Plugins Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
|
||||
</div>
|
||||
<button type="button" role="switch" aria-checked={pluginsEnabled} aria-label="Plugins Enabled"
|
||||
onClick={togglePluginsEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">User Plugin Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
|
||||
</div>
|
||||
<button type="button" role="switch" aria-checked={pluginsUploadEnabled} aria-label="User Plugin Uploads"
|
||||
onClick={togglePluginsUploadEnabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||
</div>
|
||||
<button type="button" role="switch" aria-checked={requirePluginApproval} aria-label="Require Admin Approval"
|
||||
onClick={toggleRequirePluginApproval}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plugins.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={forceEnableAll}
|
||||
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 transition-colors"
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
onClick={forceDisableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerOff className="w-3.5 h-3.5" />
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pluginsUploadEnabled && <PluginApprovalsPanel />}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
|
||||
</div>
|
||||
{plugins.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{plugins.map(plugin => (
|
||||
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{plugin.forceEnabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Forced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{plugin.permissions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-xs text-warning">
|
||||
Permissions: {plugin.permissions.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfiguringId(plugin.id)}
|
||||
title="Configure"
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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 ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deletePlugin(plugin.id, plugin.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
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<SettingsPolicy>({ ...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<PushRelayOption>) {
|
||||
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<AdminSidebarApp>) {
|
||||
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||
const defaultRelayUrl = resolveDefaultRelayUrl(policy);
|
||||
const defaultSidebarApps = policy.defaultSidebarApps ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save policy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(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 (
|
||||
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button type="button" role="switch" aria-checked={enabled} aria-label={label}
|
||||
onClick={() => 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'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Push Relays</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Relays</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Mark one as the default. Relays without a valid URL are not offered to users.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={addPushRelay}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add relay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">Bulwark relay</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
built-in
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{DEFAULT_RELAY_BASE_URL}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer shrink-0">
|
||||
<input
|
||||
type="radio"
|
||||
name="default-push-relay"
|
||||
checked={defaultRelayUrl === normalizeRelayUrl(DEFAULT_RELAY_BASE_URL)}
|
||||
onChange={() => setPushRelayUrl('')}
|
||||
className="border-input"
|
||||
/>
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(policy.pushRelays ?? []).map((relay, index) => (
|
||||
<div key={index} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={relay.label}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-8">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Relay URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={relay.url}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removePushRelay(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title="Remove relay"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer w-fit">
|
||||
<input
|
||||
type="radio"
|
||||
name="default-push-relay"
|
||||
checked={isValidRelayUrl(relay.url) && defaultRelayUrl === normalizeRelayUrl(relay.url)}
|
||||
disabled={!isValidRelayUrl(relay.url)}
|
||||
onChange={() => setPushRelayUrl(normalizeRelayUrl(relay.url))}
|
||||
className="border-input disabled:opacity-40"
|
||||
/>
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!policy.pushRelayUrlLocked}
|
||||
onChange={togglePushRelayLocked}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<Lock className="w-3 h-3" /> Lock - users are pinned to the default relay
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Default Sidebar Apps</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Apps</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={addDefaultApp}
|
||||
disabled={defaultSidebarApps.length >= 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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add app
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{defaultSidebarApps.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
No default apps. Users only see the apps they add themselves.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{defaultSidebarApps.map((app, index) => {
|
||||
const AppIcon = iconForName(app.icon);
|
||||
const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url);
|
||||
return (
|
||||
<div key={app.id} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={50}
|
||||
value={app.name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={2048}
|
||||
value={app.url}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="text-[11px] text-destructive mt-1">Enter a valid http or https URL</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||
Icon (
|
||||
<a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Tabler</a>
|
||||
)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={64}
|
||||
value={app.icon}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span
|
||||
className="shrink-0 w-8 h-8 rounded-md border border-border bg-background flex items-center justify-center text-muted-foreground"
|
||||
title={AppIcon ? app.icon : 'Unknown icon - falls back to Globe'}
|
||||
>
|
||||
{AppIcon ? <AppIcon className="w-4 h-4" /> : <span className="text-[10px]">?</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`default-app-mode-${app.id}`}
|
||||
checked={app.openMode !== 'inline'}
|
||||
onChange={() => updateDefaultApp(index, { openMode: 'tab' })}
|
||||
className="border-input"
|
||||
/>
|
||||
New tab
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`default-app-mode-${app.id}`}
|
||||
checked={app.openMode === 'inline'}
|
||||
onChange={() => updateDefaultApp(index, { openMode: 'inline' })}
|
||||
className="border-input"
|
||||
/>
|
||||
Inline
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.showOnMobile}
|
||||
onChange={() => updateDefaultApp(index, { showOnMobile: !app.showOnMobile })}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
Show on mobile
|
||||
</label>
|
||||
<div className="flex items-center gap-1 ms-auto">
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeDefaultApp(index)}
|
||||
className="text-muted-foreground hover:text-destructive p-1"
|
||||
title="Remove app"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||
const restriction = policy.restrictions[setting.key] || {};
|
||||
return (
|
||||
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{setting.label}</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||
className="rounded border-input" />
|
||||
<Lock className="w-3 h-3" /> Lock
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||
className="rounded border-input" />
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="General">
|
||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
{!!currentValue('allowCustomJmapEndpoint') && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Login Page">
|
||||
<ToggleSetting label="Show Version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Show 2FA Code Option" description="Show the "I have a 2FA code" toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Show Access Token Sign-In" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Show Remember Me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Show Heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Show Subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
<ToggleSetting
|
||||
label="Auto-pick server by email domain"
|
||||
description="When users type their email, automatically select the matching server from the list below."
|
||||
configKey="jmapServerAutoPickByDomain"
|
||||
value={currentValue('jmapServerAutoPickByDomain') as boolean}
|
||||
source={config.jmapServerAutoPickByDomain?.source}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
<JmapServersSection
|
||||
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
|
||||
source={config.jmapServers?.source}
|
||||
onChange={(next) => handleChange('jmapServers', next)}
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Document Editing (WOPI)">
|
||||
<TextSetting label="WOPI Client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" />
|
||||
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" />
|
||||
<div className="px-4 py-2.5">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Logging">
|
||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Settings Sync">
|
||||
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{label}</label>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => 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' && (
|
||||
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={label}
|
||||
onClick={() => 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'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => 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 => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<TelemetryStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [endpointDraft, setEndpointDraft] = useState('');
|
||||
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const envOverridden = status.consentSource === 'env';
|
||||
const isOn = status.consent === 'on';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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 <strong>off by
|
||||
default</strong>; one click below enables it and helps us make the product better. No
|
||||
email addresses, no hostnames, no IPs are sent.{' '}
|
||||
<a
|
||||
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Full schema and policy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Status</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{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 <code>BULWARK_TELEMETRY</code> env var.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'consent' || envOverridden || isOn}
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
|
||||
onClick={() => void setConsent('off')}
|
||||
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
|
||||
<dt className="text-muted-foreground">Last sent</dt>
|
||||
<dd>{timeAgo(status.lastSentAt)}</dd>
|
||||
<dt className="text-muted-foreground">Next scheduled</dt>
|
||||
<dd>{timeAgo(status.nextScheduledAt)}</dd>
|
||||
<dt className="text-muted-foreground">Consented at</dt>
|
||||
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-2">
|
||||
<div className="font-medium">Account activity</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="font-medium">Endpoint</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
|
||||
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={endpointDraft}
|
||||
onChange={(e) => setEndpointDraft(e.target.value)}
|
||||
placeholder={status.defaultEndpoint}
|
||||
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
|
||||
onClick={() => 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 className="h-4 w-4" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Payload preview</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Exactly what the next heartbeat would send from this install, right now.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === 'send' || !isOn}
|
||||
onClick={() => 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 className="h-4 w-4" /> Send now
|
||||
</button>
|
||||
</div>
|
||||
{sendResult && (
|
||||
<div
|
||||
className={`text-sm flex items-center gap-2 ${
|
||||
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
{sendResult.msg}
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ThemeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...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<HTMLInputElement>) {
|
||||
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<string, unknown> = { 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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const themesEnabled = policy.features.themesEnabled ?? true;
|
||||
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{policyDirty && (
|
||||
<button
|
||||
onClick={handleSavePolicy}
|
||||
disabled={savingPolicy}
|
||||
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"
|
||||
>
|
||||
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Policy
|
||||
</button>
|
||||
)}
|
||||
<label 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 cursor-pointer transition-all shadow-sm">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload Theme
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Themes Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
|
||||
</div>
|
||||
<button type="button" role="switch" aria-checked={themesEnabled} aria-label="Themes Enabled"
|
||||
onClick={toggleThemesEnabled}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">User Theme Uploads</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
|
||||
</div>
|
||||
<button type="button" role="switch" aria-checked={userThemesEnabled} aria-label="User Theme Uploads"
|
||||
onClick={toggleUserThemeUploads}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={forceEnableAll}
|
||||
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 transition-colors"
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
onClick={forceDisableAll}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerOff className="w-3.5 h-3.5" />
|
||||
Disable All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Default Theme</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
|
||||
</div>
|
||||
<select
|
||||
value={policy.themePolicy?.defaultThemeId || ''}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
<optgroup label="Built-in">
|
||||
{BUILTIN_THEME_OPTIONS
|
||||
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{themes.length > 0 && (
|
||||
<optgroup label="Admin-deployed">
|
||||
{themes
|
||||
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{BUILTIN_THEME_OPTIONS.map(theme => {
|
||||
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
|
||||
return (
|
||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{theme.name}</span>
|
||||
<button type="button" role="switch" aria-checked={!disabled} aria-label={theme.name}
|
||||
onClick={() => 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'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{themes.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
|
||||
<div className="mt-2 space-y-2">
|
||||
{themes.map(theme => {
|
||||
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
|
||||
return (
|
||||
<div key={theme.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{theme.name}</span>
|
||||
<button type="button" role="switch" aria-checked={!disabled} aria-label={theme.name}
|
||||
onClick={() => 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'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
|
||||
</div>
|
||||
{themes.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No themes installed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{themes.map(theme => (
|
||||
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{theme.name}</span>
|
||||
<span className="text-xs text-muted-foreground">v{theme.version}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
|
||||
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{theme.forceEnabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Forced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => 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 ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTheme(theme.id, theme.name)}
|
||||
title="Remove"
|
||||
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<VersionAdminStatus | null>(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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<div className="p-8 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Hourly check against the Bulwark version server. Severity is decided server-side and
|
||||
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={checking}
|
||||
onClick={() => 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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Check now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkResult && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
checkResult.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{checkResult.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Status">
|
||||
<SettingItem label="Severity">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
|
||||
>
|
||||
<ChipIcon className="h-3 w-3" />
|
||||
{chip.label}
|
||||
</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
|
||||
<span className="text-sm font-mono text-foreground">{data.current}</span>
|
||||
</SettingItem>
|
||||
{newer && (
|
||||
<SettingItem label="Latest release">
|
||||
{releaseUrl ? (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
|
||||
>
|
||||
{newer} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-mono text-foreground">{newer}</span>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
{status?.advisory && (
|
||||
<SettingItem label="Advisory">
|
||||
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
|
||||
<SettingItem label="Last checked">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last success">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="Next scheduled">
|
||||
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
|
||||
</SettingItem>
|
||||
{status?.checkedAt && (
|
||||
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
|
||||
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Source">
|
||||
<SettingItem
|
||||
label="Endpoint"
|
||||
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
|
||||
>
|
||||
<a
|
||||
href={data.endpoint}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
|
||||
>
|
||||
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
</SettingItem>
|
||||
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
|
||||
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
|
||||
{data.disabledByEnv ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Link> 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 (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) cancelLeave(); }}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={`${id}-message`}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="p-6 flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
|
||||
Unsaved changes
|
||||
</h2>
|
||||
<p id={`${id}-message`} className="mt-2 text-sm text-muted-foreground">
|
||||
You have changes that haven't been saved. If you leave now, they will be lost.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={cancelLeave}>Stay</Button>
|
||||
<Button variant="destructive" onClick={confirmLeave} className="shadow-sm">Leave</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=auth');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=branding');
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<AdminPageHeader title="Change password" description="The password for this admin panel. At least 8 characters." back={{ href: '/admin', label: 'Overview' }} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
{fields.map((field) => (
|
||||
<div key={field.id} className="space-y-1.5">
|
||||
<label htmlFor={`password-${field.id}`} className="text-sm font-medium text-foreground">{field.label}</label>
|
||||
<input
|
||||
id={`password-${field.id}`}
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
value={field.value}
|
||||
onChange={(e) => field.set(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
minLength={field.id === 'current' ? undefined : 8}
|
||||
autoComplete={field.autoComplete}
|
||||
className={cn(inputClass, 'w-full')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
<Button type="submit" size="sm" disabled={loading} className="gap-2">
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Change password
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
+28
-480
@@ -1,485 +1,33 @@
|
||||
'use client';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import { IntlProvider } from '@/components/providers/intl-provider';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { TourProvider } from '@/components/tour/tour-provider';
|
||||
import { defaultLocale } from '@/i18n/routing';
|
||||
import { AdminLayoutClient } from './_layout-client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Palette,
|
||||
Shield,
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
Package,
|
||||
Mail,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Store,
|
||||
Menu,
|
||||
X,
|
||||
} from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
|
||||
// hang we saw with both /admin/<segment> routes and ?tab= search params.
|
||||
const NAV_GROUPS: ReadonlyArray<{
|
||||
label: string;
|
||||
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
|
||||
}> = [
|
||||
{
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
items: [
|
||||
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
|
||||
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ tab: 'version', label: 'Version', icon: Package },
|
||||
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
|
||||
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
// Highlight the active tab only on /admin itself - on dynamic routes
|
||||
// (e.g. /admin/plugins/[id]) no tab is "current".
|
||||
const activeTab = pathname === '/admin' ? storeActiveTab : null;
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
const updateSeverity = useUpdateStore((s) => s.status?.severity);
|
||||
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
|
||||
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
|
||||
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
|
||||
|
||||
useEffect(() => {
|
||||
setMobileNavOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/admin/login') 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 dashboard 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]);
|
||||
|
||||
async function handleLogout() {
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
/**
|
||||
* /admin sits outside the [locale] tree, so it loads the message catalog
|
||||
* itself: the shell reuses the webmail's navigation rail, which reads its
|
||||
* labels through next-intl (and the tour context, for its shortcuts
|
||||
* dialog). The locale comes from the request (cookie or Accept-Language,
|
||||
* see i18n/request.ts) and the user's own choice takes over in the
|
||||
* browser, like on every other page.
|
||||
*/
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
} catch {
|
||||
messages = (await import(`@/locales/${defaultLocale}/common.json`)).default;
|
||||
}
|
||||
|
||||
// Don't gate the login page
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// /admin lives outside the [locale] tree, so links back to the webmail
|
||||
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
|
||||
// basePath to <Link>/router APIs - for these we prepend it manually so
|
||||
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
|
||||
const prefix = getPathPrefix();
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{NAV_GROUPS.map((group, groupIndex) => (
|
||||
<div key={group.label}>
|
||||
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
|
||||
<div className="px-3 pt-2.5 pb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.items.map(({ tab, label, icon: Icon }) => {
|
||||
const active = activeTab === tab;
|
||||
const showDot = tab === 'version' && hasUpdate;
|
||||
const handleClick = () => {
|
||||
setActiveTab(tab);
|
||||
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
|
||||
// we still need a real navigation back to /admin so the page renders.
|
||||
if (pathname !== '/admin') router.push('/admin');
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Icon className={cn(
|
||||
'w-4 h-4',
|
||||
active ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
|
||||
active ? 'ring-accent' : 'ring-background',
|
||||
updateImportant ? 'bg-red-500' : 'bg-amber-500',
|
||||
)}
|
||||
aria-label={updateImportant ? 'Important update available' : 'Update available'}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
|
||||
{!isStalwartAutoLogin && (
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className={cn(
|
||||
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
|
||||
pathname === '/admin/change-password'
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<KeyRound className={cn(
|
||||
'w-4 h-4 shrink-0',
|
||||
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)} />
|
||||
Change Password
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Slim webmail nav rail (desktop only) */}
|
||||
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
|
||||
) : (
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href={`${prefix}/`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
)}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</div>
|
||||
<a
|
||||
href={`${prefix}/settings`}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Admin Sidebar (desktop only) */}
|
||||
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{mobileNavOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={cn(
|
||||
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
|
||||
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin navigation"
|
||||
aria-hidden={!mobileNavOpen}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 overflow-x-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center min-w-0">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary me-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
|
||||
{authError ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<p className="font-medium">Admin authentication failed</p>
|
||||
<p className="mt-1 text-destructive/80">{authError}</p>
|
||||
</div>
|
||||
) : authenticated === null ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
|
||||
Loading admin panel…
|
||||
</div>
|
||||
) : authenticated ? (
|
||||
children
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile bottom nav (main webmail nav) */}
|
||||
<nav
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href={`${prefix}/`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Mail"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/calendar`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href={`${prefix}/contacts`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Contacts"
|
||||
>
|
||||
<BookUser className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||
</a>
|
||||
{filesEnabled && (
|
||||
<a
|
||||
href={`${prefix}/files`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Files"
|
||||
>
|
||||
<HardDrive className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
|
||||
title="Admin"
|
||||
aria-current="page"
|
||||
>
|
||||
<div className="relative">
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href={`${prefix}/settings`}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
<TourProvider>
|
||||
<AdminLayoutClient>{children}</AdminLayoutClient>
|
||||
</TourProvider>
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import { safeAdminNext } from '@/lib/connector/admin-next';
|
||||
import { isAdminTab, useAdminTabStore } from '@/stores/admin-tab-store';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -37,19 +36,12 @@ export default function AdminLoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A connector link to an admin panel parks its destination in ?next=
|
||||
// so logging in lands on the panel that was clicked, not the dashboard.
|
||||
// A connector link to an admin page parks its destination in ?next=
|
||||
// so logging in lands on the page that was clicked, not the overview.
|
||||
// safeAdminNext() is the gate: it is the only path here that comes out
|
||||
// of a URL rather than out of a fixed template.
|
||||
const next = safeAdminNext(new URLSearchParams(window.location.search).get('next'));
|
||||
if (next) {
|
||||
const [path, query] = next.split('?', 2);
|
||||
const tab = new URLSearchParams(query ?? '').get('tab');
|
||||
if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab);
|
||||
router.push(path);
|
||||
return;
|
||||
}
|
||||
router.push('/admin');
|
||||
// of a URL rather than out of a fixed template. A version pick (an
|
||||
// install link to one version of an extension) rides in its query.
|
||||
router.push(safeAdminNext(new URLSearchParams(window.location.search).get('next')) ?? '/admin');
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=logs');
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Download,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Star,
|
||||
Trash2,
|
||||
Check,
|
||||
Settings as SettingsIcon,
|
||||
ExternalLink,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
FileCode,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} 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 PreviewData {
|
||||
extension: {
|
||||
slug: string;
|
||||
name: string;
|
||||
type: 'plugin' | 'theme';
|
||||
pluginType: string | null;
|
||||
description: string;
|
||||
longDescription: string | null;
|
||||
tags: string[];
|
||||
permissions: string[];
|
||||
totalDownloads: number;
|
||||
featured: boolean;
|
||||
githubRepo: string | null;
|
||||
license: string | null;
|
||||
minAppVersion: string | null;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
displayName: string;
|
||||
githubLogin: string;
|
||||
avatarUrl: string | null;
|
||||
verified?: boolean;
|
||||
} | null;
|
||||
latestVersion: string | null;
|
||||
versions: Array<{
|
||||
version: string;
|
||||
changelog: string | null;
|
||||
bundleSize: number;
|
||||
minAppVersion: string | null;
|
||||
publishedAt: string | null;
|
||||
permissions: string[];
|
||||
}>;
|
||||
screenshots: Array<{ url: string; altText: string | null }>;
|
||||
themePreviews: Array<{
|
||||
variant: 'light' | 'dark';
|
||||
previewPath: string;
|
||||
colors: Record<string, string> | null;
|
||||
}>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
bundle: {
|
||||
manifest: Record<string, unknown> | null;
|
||||
source: { name: string; content: string; truncated: boolean } | null;
|
||||
size: number;
|
||||
error: string | null;
|
||||
};
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
}
|
||||
|
||||
const RISKY_PERMISSIONS = new Set([
|
||||
'mail:write',
|
||||
'mail:delete',
|
||||
'storage:write',
|
||||
'network',
|
||||
'admin',
|
||||
]);
|
||||
|
||||
export default function MarketplacePreviewPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
|
||||
const [data, setData] = useState<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [uninstalling, setUninstalling] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [showManifest, setShowManifest] = useState(false);
|
||||
|
||||
const fetchPreview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}`);
|
||||
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(() => { fetchPreview(); }, [fetchPreview]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (!data) return;
|
||||
const isUpdate = data.installed;
|
||||
const targetVersion = data.extension.latestVersion || '1.0.0';
|
||||
setInstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: data.extension.slug,
|
||||
version: targetVersion,
|
||||
type: data.extension.type,
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${data.extension.name}" installed${warnings}`,
|
||||
});
|
||||
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstall() {
|
||||
if (!data) return;
|
||||
if (!confirm(`Remove "${data.extension.name}"? 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` });
|
||||
setData(prev => prev ? { ...prev, installed: false } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
|
||||
} finally {
|
||||
setUninstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/admin/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = data.extension;
|
||||
const bundle = data.bundle;
|
||||
const isPlugin = ext.type === 'plugin';
|
||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||
const updateAvailable = data.installed
|
||||
&& !!data.installedVersion
|
||||
&& !!ext.latestVersion
|
||||
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/admin/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
|
||||
</Link>
|
||||
|
||||
{/* Banner / hero */}
|
||||
{ext.bannerUrl && (
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img
|
||||
src={ext.bannerUrl}
|
||||
alt=""
|
||||
className="block w-full max-h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{ext.iconUrl ? (
|
||||
<img
|
||||
src={ext.iconUrl}
|
||||
alt=""
|
||||
className="w-14 h-14 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isPlugin ? (
|
||||
<Puzzle className="w-7 h-7 text-muted-foreground" />
|
||||
) : (
|
||||
<SwatchBook className="w-7 h-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||
{data.installed && !updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
|
||||
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" /> Installed
|
||||
</span>
|
||||
)}
|
||||
{data.installed && updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
|
||||
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
|
||||
>
|
||||
<ArrowUpCircle className="w-3 h-3" /> Update available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
isPlugin
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
|
||||
}`}>
|
||||
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
|
||||
</span>
|
||||
{ext.author && (
|
||||
<span>by {ext.author.displayName}</span>
|
||||
)}
|
||||
{ext.latestVersion && <span>v{ext.latestVersion}</span>}
|
||||
{ext.license && <span>{ext.license}</span>}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
{ext.totalDownloads.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{data.installed ? (
|
||||
<>
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
|
||||
Update to v{ext.latestVersion}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
Manage
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleUninstall}
|
||||
disabled={uninstalling}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Uninstall
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error || versionMismatch}
|
||||
title={versionMismatch
|
||||
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
|
||||
: undefined}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versionMismatch && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Update Bulwark to install this extension</p>
|
||||
<p className="text-xs mt-0.5 opacity-90">
|
||||
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundle.error && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Could not preview bundle</p>
|
||||
<p className="text-xs mt-0.5 opacity-90">{bundle.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">About</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
|
||||
{ext.longDescription && ext.longDescription !== ext.description && (
|
||||
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
|
||||
)}
|
||||
{ext.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{ext.tags.map(tag => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
|
||||
{ext.minAppVersion && <span>Requires app v{ext.minAppVersion}+</span>}
|
||||
{bundle.size > 0 && <span>Bundle: {(bundle.size / 1024).toFixed(1)} KB</span>}
|
||||
{ext.githubRepo && (
|
||||
<a
|
||||
href={`https://github.com/${ext.githubRepo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{ext.githubRepo}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Screenshots */}
|
||||
{ext.screenshots.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{ext.screenshots.map((s, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={s.url}
|
||||
alt={s.altText || `Screenshot ${i + 1}`}
|
||||
className="w-full rounded-md border border-border bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Theme color preview */}
|
||||
{!isPlugin && ext.themePreviews.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{ext.themePreviews.map(preview => (
|
||||
<ThemeColorSwatch key={preview.variant} preview={preview} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{isPlugin && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
|
||||
</div>
|
||||
{manifestPerms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{manifestPerms.map(perm => {
|
||||
const risky = RISKY_PERMISSIONS.has(perm);
|
||||
return (
|
||||
<li
|
||||
key={perm}
|
||||
className={`flex items-center gap-2 text-sm rounded-md px-2 py-1 ${
|
||||
risky
|
||||
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
|
||||
: 'bg-muted/50 text-foreground'
|
||||
}`}
|
||||
>
|
||||
{risky && <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||
<code className="font-mono text-xs">{perm}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{frameOrigins.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
The plugin will be allowed to embed content from these origins.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{frameOrigins.map(origin => (
|
||||
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
|
||||
{origin}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Settings schema preview */}
|
||||
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">User settings</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{Object.entries(settingsSchema).map(([key, field]) => (
|
||||
<li key={key} className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-foreground">{key}</code>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
|
||||
{field.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Source / manifest disclosure */}
|
||||
{bundle.manifest && (
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowManifest(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
|
||||
</div>
|
||||
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showManifest && (
|
||||
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
|
||||
{JSON.stringify(bundle.manifest, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bundle.source && (
|
||||
<section className="border border-border rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowSource(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
|
||||
{bundle.source.truncated && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
|
||||
)}
|
||||
</div>
|
||||
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{showSource && (
|
||||
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
|
||||
{bundle.source.content}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Version history */}
|
||||
{ext.versions.length > 0 && (
|
||||
<section className="border border-border rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Version history</h2>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{ext.versions.slice(0, 5).map(v => (
|
||||
<li key={v.version} className="py-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-foreground">v{v.version}</code>
|
||||
{v.publishedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(v.publishedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.changelog && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 whitespace-pre-wrap">{v.changelog}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(v.bundleSize / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | 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 (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
|
||||
{preview.variant}
|
||||
</div>
|
||||
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
|
||||
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
|
||||
</div>
|
||||
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
|
||||
<span style={{ color: fg }}>Card surface</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
title={`${key}: ${value}`}
|
||||
className="inline-block w-4 h-4 rounded border"
|
||||
style={{ background: value, borderColor: border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=marketplace');
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
|
||||
import { DashboardTab } from './_tabs/dashboard';
|
||||
import { SettingsTab } from './_tabs/settings';
|
||||
import { BrandingTab } from './_tabs/branding';
|
||||
import { AuthTab } from './_tabs/auth';
|
||||
import { PolicyTab } from './_tabs/policy';
|
||||
import { PluginsTab } from './_tabs/plugins';
|
||||
import { ThemesTab } from './_tabs/themes';
|
||||
import { MarketplaceTab } from './_tabs/marketplace';
|
||||
import { VersionTab } from './_tabs/version';
|
||||
import { TelemetryTab } from './_tabs/telemetry';
|
||||
import { LogsTab } from './_tabs/logs';
|
||||
|
||||
export default function AdminPage() {
|
||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
|
||||
// Honour deep links from the old route structure: /admin?tab=settings
|
||||
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
|
||||
// once on mount, then strips the param so the URL stays at /admin and
|
||||
// subsequent tab clicks don't accumulate query strings.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
const fromUrl = url.searchParams.get('tab');
|
||||
if (isAdminTab(fromUrl)) {
|
||||
setActiveTab(fromUrl);
|
||||
url.searchParams.delete('tab');
|
||||
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
}, [setActiveTab]);
|
||||
|
||||
switch (activeTab) {
|
||||
case 'dashboard': return <DashboardTab />;
|
||||
case 'settings': return <SettingsTab />;
|
||||
case 'branding': return <BrandingTab />;
|
||||
case 'auth': return <AuthTab />;
|
||||
case 'policy': return <PolicyTab />;
|
||||
case 'plugins': return <PluginsTab />;
|
||||
case 'themes': return <ThemesTab />;
|
||||
case 'marketplace': return <MarketplaceTab />;
|
||||
case 'version': return <VersionTab />;
|
||||
case 'telemetry': return <TelemetryTab />;
|
||||
case 'logs': return <LogsTab />;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
|
||||
// Old deep links land on the plugins tab; the user clicks the gear again.
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=plugins');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=plugins');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=policy');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=settings');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=telemetry');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=themes');
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=version');
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import './plugin-kit.css';
|
||||
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout is then cross-origin from the "null" origin
|
||||
// to the host origin. Fonts need CORS, so there are no font imports. The one
|
||||
// stylesheet is plugin-kit.css, the styles of the plugin UI kit: Next serves
|
||||
// it from /_next/static, which answers with Access-Control-Allow-Origin: *
|
||||
// (next.config.ts), and the sandbox CSP allows styles from 'self'. Plugin
|
||||
// bundles and host API calls travel over the postMessage RPC bridge.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{/* next dev puts its indicator into every document, so each plugin
|
||||
slot showed its own badge. Plugins have no use for it. */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<style>{'nextjs-portal{display:none!important}'}</style>
|
||||
)}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Styles for the plugin UI kit (lib/plugin-sandbox/ui-kit), loaded into
|
||||
every plugin iframe. Built from the same Tailwind classes as the app's own
|
||||
every plugin iframe; scripts/build-plugin-sandbox.mjs compiles it. Built from the same Tailwind classes as the app's own
|
||||
components, so a kit Button is the app's Button. Only the kit's sources
|
||||
are scanned, which keeps the file small.
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||
// approval) is enforced host-side before this route is ever framed; the page
|
||||
// itself carries no extra privilege.
|
||||
//
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PrivilegedPluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { sandboxDocument } from '@/lib/plugin-sandbox/sandbox-document';
|
||||
|
||||
// Privileged-tier sandbox document. Identical to /plugin-sandbox, but the
|
||||
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||
// approval) is enforced host-side before this route is ever framed; the
|
||||
// document itself carries no extra privilege.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export function GET(request: Request): Promise<Response> {
|
||||
return sandboxDocument(request);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts. With force-static, those scripts
|
||||
// render without a nonce and the strict sandbox CSP blocks them.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sandboxDocument } from '@/lib/plugin-sandbox/sandbox-document';
|
||||
|
||||
// The untrusted plugin iframe document (sandbox="allow-scripts", opaque
|
||||
// origin). Dynamic: the CSP nonce from proxy.ts is per request.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export function GET(request: Request): Promise<Response> {
|
||||
return sandboxDocument(request);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { readFileSync, rmSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// GET /api/admin/config hides the session secret and the OAuth client secrets
|
||||
// behind `hasValue` so a stolen admin session cannot lift them in one
|
||||
// request. The same secrets leaked through two other doors: every PATCH wrote
|
||||
// old and new values verbatim into the audit log, which GET /api/admin/audit
|
||||
// hands back, and the per-server client secrets in `jmapServers` were returned
|
||||
// in full. A PATCH could also replace the session secret itself, making the
|
||||
// caller the holder of the key behind every session cookie.
|
||||
|
||||
const configDir = await vi.hoisted(async () => {
|
||||
const { mkdtempSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bulwark-config-secrets-'));
|
||||
process.env.ADMIN_CONFIG_DIR = dir;
|
||||
process.env.ADMIN_STATE_DIR = join(dir, 'state');
|
||||
delete process.env.SESSION_SECRET;
|
||||
delete process.env.SESSION_SECRET_FILE;
|
||||
return dir;
|
||||
});
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
|
||||
import { GET as getConfig, PATCH as patchConfig, DELETE as deleteConfig } from '../config/route';
|
||||
import { GET as getAudit } from '../audit/route';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
|
||||
function req(url: string, method: string, body?: unknown) {
|
||||
return new NextRequest(`http://localhost${url}`, {
|
||||
method,
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
|
||||
});
|
||||
}
|
||||
|
||||
async function auditText(): Promise<string> {
|
||||
const res = await getAudit(req('/api/admin/audit?limit=200', 'GET'));
|
||||
expect(res.status).toBe(200);
|
||||
return JSON.stringify(await res.json());
|
||||
}
|
||||
|
||||
function auditFileText(): string {
|
||||
return readFileSync(path.join(configDir, 'state', 'audit.log'), 'utf-8');
|
||||
}
|
||||
|
||||
const SERVER = {
|
||||
id: 'bridge',
|
||||
label: 'Bridge',
|
||||
url: 'https://bridge.example.com',
|
||||
oauth: { clientId: 'bulwark', clientSecret: 'per-server-s3cret', issuerUrl: 'https://idp.example.com' },
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await configManager.ensureLoaded();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('admin config secrets stay on the server', () => {
|
||||
it('does not write the OAuth client secret into the audit log or return it from the audit API', async () => {
|
||||
const res = await patchConfig(req('/api/admin/config', 'PATCH', { oauthClientSecret: 'global-s3cret-1' }));
|
||||
expect(res.status).toBe(200);
|
||||
await patchConfig(req('/api/admin/config', 'PATCH', { oauthClientSecret: 'global-s3cret-2' }));
|
||||
const revert = await deleteConfig(req('/api/admin/config', 'DELETE', { key: 'oauthClientSecret' }));
|
||||
expect(revert.status).toBe(200);
|
||||
|
||||
const text = await auditText();
|
||||
expect(text).not.toContain('global-s3cret');
|
||||
expect(text).toContain('[redacted]');
|
||||
expect(auditFileText()).not.toContain('global-s3cret');
|
||||
});
|
||||
|
||||
it('does not return per-server client secrets from GET /api/admin/config or the audit API, but keeps them on save', async () => {
|
||||
expect((await patchConfig(req('/api/admin/config', 'PATCH', { jmapServers: [SERVER] }))).status).toBe(200);
|
||||
|
||||
const shown = await (await getConfig(req('/api/admin/config', 'GET'))).json();
|
||||
expect(JSON.stringify(shown)).not.toContain('per-server-s3cret');
|
||||
expect(shown.jmapServers.value[0].oauth).toEqual({
|
||||
clientId: 'bulwark',
|
||||
issuerUrl: 'https://idp.example.com',
|
||||
hasClientSecret: true,
|
||||
});
|
||||
expect(await auditText()).not.toContain('per-server-s3cret');
|
||||
expect(auditFileText()).not.toContain('per-server-s3cret');
|
||||
|
||||
// The dashboard sends the entry back as it was shown: the stored secret survives.
|
||||
const resent = shown.jmapServers.value;
|
||||
resent[0].label = 'Bridge (renamed)';
|
||||
expect((await patchConfig(req('/api/admin/config', 'PATCH', { jmapServers: resent }))).status).toBe(200);
|
||||
let stored = parseJmapServers(configManager.get('jmapServers'));
|
||||
expect(stored[0]!.label).toBe('Bridge (renamed)');
|
||||
expect(stored[0]!.oauth!.clientSecret).toBe('per-server-s3cret');
|
||||
expect(stored[0]!.oauth).not.toHaveProperty('hasClientSecret');
|
||||
|
||||
// A typed secret replaces it; an explicit empty one clears it.
|
||||
await patchConfig(req('/api/admin/config', 'PATCH', { jmapServers: [{ ...SERVER, oauth: { ...SERVER.oauth, clientSecret: 'new-s3cret' } }] }));
|
||||
stored = parseJmapServers(configManager.get('jmapServers'));
|
||||
expect(stored[0]!.oauth!.clientSecret).toBe('new-s3cret');
|
||||
await patchConfig(req('/api/admin/config', 'PATCH', { jmapServers: [{ ...SERVER, oauth: { clientId: 'bulwark', clientSecret: '' } }] }));
|
||||
stored = parseJmapServers(configManager.get('jmapServers'));
|
||||
expect(stored[0]!.oauth).toEqual({ clientId: 'bulwark' });
|
||||
|
||||
// Dropping the OAuth block drops the secret with it.
|
||||
await patchConfig(req('/api/admin/config', 'PATCH', { jmapServers: [SERVER] }));
|
||||
await patchConfig(req('/api/admin/config', 'PATCH', { jmapServers: [{ id: 'bridge', label: 'Bridge', url: SERVER.url }] }));
|
||||
stored = parseJmapServers(configManager.get('jmapServers'));
|
||||
expect(stored[0]!.oauth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to change the session secret from the dashboard', async () => {
|
||||
const before = getSessionSecret();
|
||||
const res = await patchConfig(req('/api/admin/config', 'PATCH', { sessionSecret: 'x'.repeat(48) }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(getSessionSecret()).toBe(before);
|
||||
expect(configManager.get('sessionSecret', '')).toBe('');
|
||||
expect(await auditText()).not.toContain('x'.repeat(48));
|
||||
});
|
||||
|
||||
it('redacts entries an older version wrote with the secret in clear', async () => {
|
||||
const { appendFileSync } = await import('node:fs');
|
||||
appendFileSync(
|
||||
path.join(configDir, 'state', 'audit.log'),
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
action: 'config.update',
|
||||
detail: { changes: [{ key: 'sessionSecret', old: 'legacy-old-secret', new: 'legacy-new-secret' }] },
|
||||
ip: '127.0.0.1',
|
||||
}) + '\n',
|
||||
);
|
||||
const text = await auditText();
|
||||
expect(text).not.toContain('legacy-old-secret');
|
||||
expect(text).not.toContain('legacy-new-secret');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Two guards in front of a marketplace install: the version must be a
|
||||
// version string (it goes into a URL, the registry and the history directory
|
||||
// name, and a non-string broke the next update), and an "update" must not
|
||||
// quietly install a release older than the one installed, which is what a
|
||||
// directory that lists an old, vulnerable release as the latest would get.
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
|
||||
vi.mock('@/lib/admin/csp-frame-origins', () => ({ invalidateFrameOriginsCache: () => {} }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, get: () => undefined },
|
||||
}));
|
||||
const installed: Record<string, { version: string } | null> = {};
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPlugin: async (id: string) => installed[`plugin:${id}`] ?? null,
|
||||
getTheme: async (id: string) => installed[`theme:${id}`] ?? null,
|
||||
getPluginRegistry: async () => ({ plugins: [] }),
|
||||
getThemeRegistry: async () => ({ themes: [] }),
|
||||
savePlugin: vi.fn(),
|
||||
saveTheme: vi.fn(),
|
||||
themeCSSResolver: async () => () => null,
|
||||
}));
|
||||
|
||||
import { POST } from '../marketplace/route';
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response('nope', { status: 502 }));
|
||||
|
||||
function install(body: unknown) {
|
||||
return POST(new NextRequest('http://localhost/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(installed)) delete installed[key];
|
||||
fetchMock.mockClear();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
describe('marketplace install guards', () => {
|
||||
it('refuses a version that is not a version string, before touching the directory', async () => {
|
||||
for (const version of [{}, ['1.0.0'], 'x'.repeat(100), '1.0', 'latest', '../1.0.0']) {
|
||||
const res = await install({ slug: 'jitsi-meet', version, type: 'plugin' });
|
||||
expect(res.status, JSON.stringify(version)).toBe(400);
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to install a release older than the installed one unless asked to', async () => {
|
||||
installed['plugin:jitsi-meet'] = { version: '2.1.0' };
|
||||
const older = await install({ slug: 'jitsi-meet', version: '2.0.9', type: 'plugin' });
|
||||
expect(older.status).toBe(409);
|
||||
const refused = await older.json();
|
||||
expect(refused.error).toMatch(/older than the installed 2\.1\.0/);
|
||||
expect(refused.downgrade).toEqual({ installedVersion: '2.1.0' });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
installed['theme:nord'] = { version: '1.5.0' };
|
||||
expect((await install({ slug: 'nord', version: '1.4.0', type: 'theme' })).status).toBe(409);
|
||||
|
||||
// The deliberate way back goes through with `force` (the admin UI asks
|
||||
// first, as it does for a withdrawn version)...
|
||||
const back = await install({ slug: 'jitsi-meet', version: '2.0.9', type: 'plugin', force: true });
|
||||
expect(back.status).not.toBe(409);
|
||||
expect(fetchMock).toHaveBeenCalled(); // the guard let it reach the (stubbed) directory
|
||||
// ...and a newer release, or the same one, was never a downgrade.
|
||||
expect((await install({ slug: 'jitsi-meet', version: '2.1.0', type: 'plugin' })).status).not.toBe(409);
|
||||
expect((await install({ slug: 'jitsi-meet', version: '2.2.0-beta.1', type: 'plugin' })).status).not.toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Writes from the admin panel must answer 409 with the read-only message
|
||||
// instead of a bare 500, and must not change anything on the way (#1249).
|
||||
|
||||
// Hoisted so the dirs are set before any admin module loads.
|
||||
const configDir = await vi.hoisted(async () => {
|
||||
const { mkdtempSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bulwark-readonly-'));
|
||||
process.env.ADMIN_CONFIG_DIR = dir;
|
||||
process.env.ADMIN_STATE_DIR = join(dir, 'state');
|
||||
return dir;
|
||||
});
|
||||
|
||||
let readOnly = false;
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
|
||||
vi.mock('@/lib/admin/paths', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/admin/paths')>();
|
||||
return {
|
||||
...actual,
|
||||
isConfigReadOnly: () => readOnly,
|
||||
assertWritable: (operation: string) => {
|
||||
if (readOnly) throw new actual.ConfigReadOnlyError(operation);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { PATCH as patchConfig, DELETE as deleteConfig } from '../config/route';
|
||||
import { PUT as putPolicy } from '../policy/route';
|
||||
import { DELETE as deleteBranding } from '../branding/route';
|
||||
import { POST as changePassword } from '../change-password/route';
|
||||
import { POST as decidePluginApproval } from '../plugin-approvals/route';
|
||||
import { setInitialAdminPassword, verifyAdminPassword } from '@/lib/admin/password';
|
||||
import { requestApproval, listApprovals } from '@/lib/admin/plugin-approvals';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
function req(url: string, method: string, body: unknown) {
|
||||
return new NextRequest(`http://localhost${url}`, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
async function expectReadOnly(res: Response) {
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).error).toMatch(/configuration is read-only/);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await setInitialAdminPassword('old-password-123');
|
||||
await requestApproval('demo-plugin', 'abcdef0123456789', { name: 'Demo', version: '1.0.0' } as never, 'user@example.org');
|
||||
await configManager.ensureLoaded();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
readOnly = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('admin writes on a read-only config (#1249)', () => {
|
||||
it('PATCH /api/admin/config answers 409 with the reason', async () => {
|
||||
await expectReadOnly(await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Changed' })));
|
||||
expect(configManager.get('appName')).not.toBe('Changed');
|
||||
});
|
||||
|
||||
it('DELETE /api/admin/config answers 409 with the reason', async () => {
|
||||
await expectReadOnly(await deleteConfig(req('/api/admin/config', 'DELETE', { key: 'appName' })));
|
||||
});
|
||||
|
||||
it('PUT /api/admin/policy answers 409 with the reason', async () => {
|
||||
await expectReadOnly(await putPolicy(req('/api/admin/policy', 'PUT', { restrictions: {} })));
|
||||
});
|
||||
|
||||
it('DELETE /api/admin/branding refuses before deleting the file', async () => {
|
||||
const dir = path.join(configDir, 'branding');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, 'faviconUrl.png');
|
||||
writeFileSync(file, 'png');
|
||||
|
||||
await expectReadOnly(await deleteBranding(req('/api/admin/branding', 'DELETE', { slot: 'faviconUrl' })));
|
||||
expect(existsSync(file)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/admin/change-password refuses and keeps the old password', async () => {
|
||||
await expectReadOnly(await changePassword(req('/api/admin/change-password', 'POST', {
|
||||
currentPassword: 'old-password-123',
|
||||
newPassword: 'new-password-456',
|
||||
})));
|
||||
expect(await verifyAdminPassword('old-password-123')).toBe(true);
|
||||
expect(await verifyAdminPassword('new-password-456')).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/admin/plugin-approvals refuses and leaves the request pending', async () => {
|
||||
await expectReadOnly(await decidePluginApproval(req('/api/admin/plugin-approvals', 'POST', {
|
||||
pluginId: 'demo-plugin',
|
||||
bundleHash: 'abcdef0123456789',
|
||||
decision: 'approved',
|
||||
})));
|
||||
expect((await listApprovals()).map(e => e.status)).toEqual(['pending']);
|
||||
});
|
||||
|
||||
it('still saves when the config is writable', async () => {
|
||||
readOnly = false;
|
||||
const res = await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Writable' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(configManager.get('appName')).toBe('Writable');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
|
||||
import {
|
||||
parseDomainBranding,
|
||||
type DomainBrandingEntry,
|
||||
@@ -12,6 +12,7 @@ import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
function getBrandingDir(): string {
|
||||
return path.join(getConfigDir(), 'branding');
|
||||
@@ -124,6 +125,9 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// Refuse before touching the branding dir: the config write that
|
||||
// follows would fail only after the files had already changed.
|
||||
assertWritable('change branding');
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const formData = await request.formData();
|
||||
@@ -206,6 +210,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -224,6 +230,9 @@ export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// Refuse before touching the branding dir: the config write that
|
||||
// follows would fail only after the files had already changed.
|
||||
assertWritable('change branding');
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
|
||||
@@ -271,6 +280,8 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { changeAdminPassword } from '@/lib/admin/password';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/**
|
||||
* POST /api/admin/change-password - Change admin password
|
||||
@@ -31,6 +32,8 @@ export async function POST(request: NextRequest) {
|
||||
await auditLog('admin.change-password', {}, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { parseJmapServers, redactJmapServersForAdmin, withStoredClientSecrets } from '@/lib/admin/jmap-servers';
|
||||
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||
import { initAdminPassword, isAdminEnabled } from '@/lib/admin/password';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
// can warn about a placeholder session secret without us ever returning the
|
||||
@@ -40,6 +42,9 @@ export async function GET(request: NextRequest) {
|
||||
// Nothing configured, but the secret generated on first start is there.
|
||||
(key === 'sessionSecret' && entry.source === 'default' && hasSessionSecret());
|
||||
safe[key] = { source: entry.source, hasValue };
|
||||
} else if (key === 'jmapServers') {
|
||||
// Per-server OAuth client secrets are secrets like the global one.
|
||||
safe[key] = { source: entry.source, value: redactJmapServersForAdmin(parseJmapServers(entry.value)) };
|
||||
} else {
|
||||
safe[key] = entry;
|
||||
}
|
||||
@@ -76,6 +81,24 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// The session secret is the key behind every session cookie and the
|
||||
// synced-settings encryption. The dashboard hides its value for a reason;
|
||||
// letting an admin session replace it with a value of its choosing would
|
||||
// hand that session the key (and sign everyone else out). Only the setup
|
||||
// wizard writes it, before any session exists.
|
||||
if ('sessionSecret' in updates) {
|
||||
return NextResponse.json({
|
||||
error: 'sessionSecret cannot be changed from the dashboard. Set SESSION_SECRET in the environment instead.',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// The directory is fetched server-side on the admin's behalf and its JSON
|
||||
// handed back, so it must not name an internal host (lib/admin/directory-url.ts).
|
||||
if ('extensionDirectoryUrl' in updates) {
|
||||
const problem = await checkExtensionDirectoryUrl(updates.extensionDirectoryUrl);
|
||||
if (problem) return NextResponse.json({ error: problem }, { status: 400 });
|
||||
}
|
||||
|
||||
// Enum keys only accept their declared values; a stray string would
|
||||
// otherwise be persisted and fail closed (or open) at the read site.
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
@@ -105,7 +128,11 @@ export async function PATCH(request: NextRequest) {
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseJmapServers(incoming);
|
||||
// The dashboard never sees the stored client secrets (GET redacts
|
||||
// them), so an entry that omits one keeps it.
|
||||
const sanitized = parseJmapServers(
|
||||
withStoredClientSecrets(incoming, parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
@@ -145,6 +172,8 @@ export async function PATCH(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -175,6 +204,8 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { listHistory } from '@/lib/admin/extension-history';
|
||||
import { getPlugin, getTheme } from '@/lib/admin/plugin-registry';
|
||||
import { EXTENSION_ID_RE } from '@/lib/extension-package';
|
||||
|
||||
function parseKind(value: string): 'plugin' | 'theme' | null {
|
||||
return value === 'plugin' || value === 'theme' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/extensions/[kind]/[id]/history
|
||||
*
|
||||
* The versions of an installed plugin or theme this server still has a copy
|
||||
* of, newest replacement first, plus what is installed now. Each one can be
|
||||
* put back with POST .../rollback.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ kind: string; id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { kind: rawKind, id } = await params;
|
||||
const kind = parseKind(rawKind);
|
||||
if (!kind) return NextResponse.json({ error: 'Unknown extension kind' }, { status: 400 });
|
||||
if (!EXTENSION_ID_RE.test(id)) return NextResponse.json({ error: 'Invalid extension id' }, { status: 400 });
|
||||
|
||||
const current = kind === 'plugin' ? await getPlugin(id) : await getTheme(id);
|
||||
const history = await listHistory(kind, id);
|
||||
return NextResponse.json(
|
||||
{
|
||||
kind,
|
||||
id,
|
||||
current: current ? { version: current.version, updatedAt: current.updatedAt, source: current.source ?? null } : null,
|
||||
history,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Extension history error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { HistoryError, readHistoryEntry } from '@/lib/admin/extension-history';
|
||||
import { getPlugin, getTheme, savePlugin, saveTheme } from '@/lib/admin/plugin-registry';
|
||||
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import { EXTENSION_ID_RE } from '@/lib/extension-package';
|
||||
|
||||
/**
|
||||
* POST /api/admin/extensions/[kind]/[id]/rollback
|
||||
* Body: { version: string }
|
||||
*
|
||||
* Puts a previously installed version back from the server's own copy
|
||||
* (lib/admin/extension-history.ts). The directory is not involved, so this
|
||||
* works offline and for versions the directory has since withdrawn. The
|
||||
* extension keeps whether it is enabled or forced; only the code and the
|
||||
* manifest-derived fields go back to what that version had.
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ kind: string; id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { kind, id } = await params;
|
||||
if (kind !== 'plugin' && kind !== 'theme') {
|
||||
return NextResponse.json({ error: 'Unknown extension kind' }, { status: 400 });
|
||||
}
|
||||
if (!EXTENSION_ID_RE.test(id)) return NextResponse.json({ error: 'Invalid extension id' }, { status: 400 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const version = typeof body?.version === 'string' ? body.version.trim() : '';
|
||||
if (!version || version.length > 64) {
|
||||
return NextResponse.json({ error: 'Missing version' }, { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (kind === 'plugin') {
|
||||
const current = await getPlugin(id);
|
||||
if (!current) return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
if (current.version === version) {
|
||||
return NextResponse.json({ error: `Version ${version} is already installed` }, { status: 409 });
|
||||
}
|
||||
const kept = await readHistoryEntry('plugin', id, version);
|
||||
const plugin = {
|
||||
...kept.entry,
|
||||
id,
|
||||
enabled: current.enabled,
|
||||
...(current.forceEnabled !== undefined ? { forceEnabled: current.forceEnabled } : {}),
|
||||
installedAt: current.installedAt,
|
||||
updatedAt: now,
|
||||
};
|
||||
await savePlugin(plugin, kept.code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.rollback', { id, from: current.version, to: version }, ip);
|
||||
const saved = await getPlugin(id);
|
||||
return NextResponse.json({ success: true, plugin: saved ?? plugin });
|
||||
}
|
||||
|
||||
const current = await getTheme(id);
|
||||
if (!current) return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||
if (current.version === version) {
|
||||
return NextResponse.json({ error: `Version ${version} is already installed` }, { status: 409 });
|
||||
}
|
||||
const kept = await readHistoryEntry('theme', id, version);
|
||||
const theme = {
|
||||
...kept.entry,
|
||||
id,
|
||||
enabled: current.enabled,
|
||||
...(current.forceEnabled !== undefined ? { forceEnabled: current.forceEnabled } : {}),
|
||||
installedAt: current.installedAt,
|
||||
updatedAt: now,
|
||||
};
|
||||
await saveTheme(theme, kept.css, kept.skin);
|
||||
await auditLog('theme.rollback', { id, from: current.version, to: version }, ip);
|
||||
const saved = await getTheme(id);
|
||||
return NextResponse.json({ success: true, theme: saved ?? theme });
|
||||
} catch (error) {
|
||||
if (error instanceof HistoryError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Extension rollback error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Rollback failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
const audit: Array<[string, Record<string, unknown>]> = [];
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async (a: string, d: Record<string, unknown>) => { audit.push([a, d]); }) }));
|
||||
vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/admin/csp-frame-origins')>()),
|
||||
invalidateFrameOriginsCache: vi.fn(),
|
||||
}));
|
||||
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), 'bw-rollback-'));
|
||||
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
|
||||
vi.resetModules();
|
||||
audit.length = 0;
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const PLUGIN = {
|
||||
id: 'demo', name: 'Demo', version: '1.0.0', author: 'a', description: '', type: 'hook',
|
||||
permissions: ['email:read'], entrypoint: 'index.js', enabled: true, installedAt: '2026-01-01T00:00:00.000Z', updatedAt: 'x',
|
||||
};
|
||||
|
||||
const call = async (kind: string, id: string, body: unknown) => {
|
||||
const { POST } = await import('../[kind]/[id]/rollback/route');
|
||||
return POST(
|
||||
new NextRequest(`http://localhost/api/admin/extensions/${kind}/${id}/rollback`, {
|
||||
method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
{ params: Promise.resolve({ kind, id }) },
|
||||
);
|
||||
};
|
||||
|
||||
describe('POST /api/admin/extensions/[kind]/[id]/rollback', () => {
|
||||
it('puts the previous version back and keeps the current enabled and forced flags', async () => {
|
||||
const registry = await import('@/lib/admin/plugin-registry');
|
||||
await registry.savePlugin(PLUGIN, 'v1()');
|
||||
await registry.savePlugin({ ...PLUGIN, version: '1.1.0', permissions: ['email:read', 'email:send'], enabled: false, forceEnabled: false }, 'v2()');
|
||||
|
||||
const res = await call('plugin', 'demo', { version: '1.0.0' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const now = (await registry.getPlugin('demo'))!;
|
||||
expect(now.version).toBe('1.0.0');
|
||||
expect(now.permissions).toEqual(['email:read']);
|
||||
expect(now.enabled).toBe(false);
|
||||
expect(now.forceEnabled).toBe(false);
|
||||
expect(now.installedAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(await registry.getPluginBundle('demo')).toBe('v1()');
|
||||
expect(audit).toEqual([['plugin.rollback', { id: 'demo', from: '1.1.0', to: '1.0.0' }]]);
|
||||
|
||||
// The version that was replaced is now the one that can be gone back to.
|
||||
const { GET } = await import('../[kind]/[id]/history/route');
|
||||
const history = await (await GET(new NextRequest('http://localhost/x'), { params: Promise.resolve({ kind: 'plugin', id: 'demo' }) })).json();
|
||||
expect(history.current.version).toBe('1.0.0');
|
||||
expect(history.history.map((e: { version: string }) => e.version)).toEqual(['1.1.0']);
|
||||
});
|
||||
|
||||
it('answers 404 when no copy of that version is kept', async () => {
|
||||
const registry = await import('@/lib/admin/plugin-registry');
|
||||
await registry.savePlugin(PLUGIN, 'v1()');
|
||||
const res = await call('plugin', 'demo', { version: '0.9.0' });
|
||||
expect(res.status).toBe(404);
|
||||
expect((await registry.getPlugin('demo'))!.version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('answers 409 when that version is already installed', async () => {
|
||||
const registry = await import('@/lib/admin/plugin-registry');
|
||||
await registry.savePlugin(PLUGIN, 'v1()');
|
||||
expect((await call('plugin', 'demo', { version: '1.0.0' })).status).toBe(409);
|
||||
});
|
||||
|
||||
it('refuses an unknown kind and a bad id', async () => {
|
||||
expect((await call('widget', 'demo', { version: '1.0.0' })).status).toBe(400);
|
||||
expect((await call('plugin', '../demo', { version: '1.0.0' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('rolls a theme back with its skin', async () => {
|
||||
const registry = await import('@/lib/admin/plugin-registry');
|
||||
const THEME = { id: 'night', name: 'Night', version: '1.0.0', author: 'a', description: '', variants: ['dark'], enabled: true, installedAt: 'x', updatedAt: 'x' };
|
||||
await registry.saveTheme(THEME, 'body{}', ':root{--a:1}');
|
||||
await registry.saveTheme({ ...THEME, version: '2.0.0' }, 'body{color:red}', null);
|
||||
|
||||
const res = await call('theme', 'night', { version: '1.0.0' });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await registry.getTheme('night'))!.version).toBe('1.0.0');
|
||||
expect(await registry.getThemeCSS('night')).toBe('body{}');
|
||||
expect(await registry.getThemeSkin('night')).toBe(':root{--a:1}');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { openPackage, readManifest, readText } from '@/lib/extension-package';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { checkBundleSignature, type SignatureCheck } from '@/lib/admin/directory-keys';
|
||||
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
}
|
||||
import { RELEASE_VERSION } from '@/lib/release-version';
|
||||
import {
|
||||
APP_VERSION,
|
||||
DirectoryError,
|
||||
compatQuery,
|
||||
directoryJson,
|
||||
getDirectoryChannel,
|
||||
getDirectoryUrl,
|
||||
historyFor,
|
||||
installedExtensions,
|
||||
installedStateOf,
|
||||
toListing,
|
||||
v2Url,
|
||||
type DirectoryDetail,
|
||||
type DirectoryVersion,
|
||||
} from '@/lib/admin/marketplace-directory';
|
||||
|
||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
|
||||
/** A version row as the detail page renders it. */
|
||||
function versionRow(v: DirectoryVersion) {
|
||||
return {
|
||||
version: v.version,
|
||||
channel: v.channel,
|
||||
publishedAt: v.publishedAt,
|
||||
minAppVersion: v.minAppVersion,
|
||||
maxAppVersion: v.maxAppVersion,
|
||||
permissions: v.permissions ?? [],
|
||||
size: v.size,
|
||||
sha256: v.sha256,
|
||||
yanked: v.yanked,
|
||||
changelog: v.changelog ?? null,
|
||||
downloads: v.downloads ?? 0,
|
||||
signed: !!v.signature,
|
||||
commit: v.provenance?.source?.commit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace/[slug]
|
||||
* Returns full preview info for an extension: directory metadata,
|
||||
* the bundle's manifest, a (truncated) source preview, and install status.
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
* GET /api/admin/marketplace/[slug]?version=
|
||||
*
|
||||
* Everything the preview page shows for one extension: the directory's
|
||||
* listing, every version it has (withdrawn ones included, so an installed
|
||||
* version that was withdrawn is shown as such), the bundle of the version
|
||||
* being previewed (its manifest and a capped source listing, after the
|
||||
* signature check), what is installed here, and the versions this server
|
||||
* still keeps for a rollback.
|
||||
*
|
||||
* `version` picks the version to preview - an install link from the
|
||||
* directory names one. Without it, or when the directory no longer has it,
|
||||
* the newest version this Bulwark can run is previewed.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -31,50 +65,55 @@ export async function GET(
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
const requested = request.nextUrl.searchParams.get('version');
|
||||
if (requested && !RELEASE_VERSION.test(requested)) {
|
||||
return NextResponse.json({ error: 'Invalid version' }, { status: 400 });
|
||||
}
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const channel = getDirectoryChannel();
|
||||
|
||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
|
||||
if (process.env.NEXT_PUBLIC_APP_VERSION) detailUrl.searchParams.set('appVersion', process.env.NEXT_PUBLIC_APP_VERSION);
|
||||
const detailRes = await fetch(detailUrl.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!detailRes.ok) {
|
||||
const status = detailRes.status === 404 ? 404 : 502;
|
||||
return NextResponse.json(
|
||||
{ error: status === 404 ? 'Extension not found' : 'Directory request failed' },
|
||||
{ status },
|
||||
);
|
||||
// 1. Listing and every version, from the directory.
|
||||
let detail: DirectoryDetail;
|
||||
let versions: DirectoryVersion[];
|
||||
try {
|
||||
const base = `/extensions/${encodeURIComponent(slug)}`;
|
||||
[detail, versions] = await Promise.all([
|
||||
directoryJson<{ data: DirectoryDetail }>(v2Url(directoryUrl, base, compatQuery())).then((r) => r.data),
|
||||
directoryJson<{ data: DirectoryVersion[] }>(
|
||||
v2Url(directoryUrl, `${base}/versions`, { include: channel === 'beta' ? 'yanked,beta' : 'yanked' }),
|
||||
).then((r) => r.data ?? []),
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err instanceof DirectoryError) {
|
||||
return NextResponse.json(
|
||||
{ error: err.status === 404 ? 'Extension not found' : err.message },
|
||||
{ status: err.status === 404 ? 404 : 502 },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const detailJson = await detailRes.json();
|
||||
const extension = detailJson.data as Record<string, unknown> | undefined;
|
||||
if (!extension) {
|
||||
return NextResponse.json({ error: 'Extension not found' }, { status: 404 });
|
||||
}
|
||||
const type: 'plugin' | 'theme' = detail.type === 'theme' ? 'theme' : 'plugin';
|
||||
|
||||
const type = extension.type as 'plugin' | 'theme';
|
||||
const latestVersion = (extension.latestVersion as { version?: string } | null)?.version
|
||||
?? null;
|
||||
// 2. Which version to preview.
|
||||
const requestedRow = requested ? versions.find((v) => v.version === requested) ?? null : null;
|
||||
const preview = requestedRow ?? detail.latest ?? detail.newest ?? versions[0] ?? null;
|
||||
|
||||
// 2. Pull the bundle so we can show what's actually inside.
|
||||
// 3. Pull that bundle so the admin sees what is actually inside.
|
||||
let manifest: Record<string, unknown> | null = null;
|
||||
let sourcePreview: { name: string; content: string; truncated: boolean } | null = null;
|
||||
let bundleError: string | null = null;
|
||||
let bundleSize = 0;
|
||||
let signature: SignatureCheck | null = null;
|
||||
|
||||
if (latestVersion) {
|
||||
if (preview) {
|
||||
try {
|
||||
const bundleUrl = new URL(
|
||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||
const bundleUrl = v2Url(
|
||||
directoryUrl,
|
||||
`/extensions/${encodeURIComponent(slug)}/versions/${encodeURIComponent(preview.version)}/bundle`,
|
||||
);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const bundleRes = await fetch(bundleUrl.toString(), { signal: AbortSignal.timeout(30000) });
|
||||
|
||||
if (!bundleRes.ok) {
|
||||
bundleError = `Bundle download failed (${bundleRes.status})`;
|
||||
@@ -85,14 +124,14 @@ export async function GET(
|
||||
directoryUrl,
|
||||
configuredRootKeys: configManager.get<string>('extensionDirectoryRootKeys'),
|
||||
slug,
|
||||
version: latestVersion,
|
||||
kind: type === 'theme' ? 'theme' : 'plugin',
|
||||
version: preview.version,
|
||||
kind: type,
|
||||
bytes: buffer,
|
||||
headers: bundleRes.headers,
|
||||
});
|
||||
// Opening the preview must not inflate an arbitrary bundle in
|
||||
// full: every read goes through the capped package reader.
|
||||
const pkg = await openPackage(buffer, type === 'theme' ? 'theme' : 'plugin');
|
||||
const pkg = await openPackage(buffer, type);
|
||||
try {
|
||||
manifest = await readManifest(pkg);
|
||||
} catch (err) {
|
||||
@@ -122,68 +161,33 @@ export async function GET(
|
||||
bundleError = 'Extension has no published version';
|
||||
}
|
||||
|
||||
// 3. Install status (slug is used as the registry id at install time)
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
const installedEntry = type === 'theme'
|
||||
? themeRegistry.themes.find((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.find((p) => p.id === slug);
|
||||
const installed = installedEntry !== undefined;
|
||||
const installedVersion = installedEntry?.version ?? null;
|
||||
|
||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||
const screenshots = Array.isArray(extension.screenshots)
|
||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||
altText: s.altText ?? null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Strip the heavy `manifest` blob from versions when echoing the directory data.
|
||||
const versions = Array.isArray(extension.versions)
|
||||
? (extension.versions as Array<Record<string, unknown>>).map((v) => ({
|
||||
version: v.version,
|
||||
changelog: v.changelog,
|
||||
bundleSize: v.bundleSize,
|
||||
minAppVersion: v.minAppVersion,
|
||||
publishedAt: v.publishedAt,
|
||||
permissions: v.permissions,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
// 4. What is installed here, and what this server still keeps.
|
||||
const installed = await installedExtensions();
|
||||
const state = installedStateOf(installed, slug, type);
|
||||
const history = await historyFor(type, slug);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
extension: {
|
||||
slug: extension.slug,
|
||||
name: extension.name,
|
||||
type: extension.type,
|
||||
pluginType: extension.pluginType ?? null,
|
||||
description: extension.description,
|
||||
longDescription: extension.longDescription ?? null,
|
||||
tags: extension.tags ?? [],
|
||||
permissions: extension.permissions ?? [],
|
||||
totalDownloads: extension.totalDownloads ?? 0,
|
||||
featured: extension.featured ?? false,
|
||||
githubRepo: extension.githubRepo ?? null,
|
||||
license: extension.license ?? null,
|
||||
minAppVersion: extension.minAppVersion ?? null,
|
||||
iconUrl: fileUrl(extension.iconPath),
|
||||
bannerUrl: fileUrl(extension.bannerPath),
|
||||
author: extension.author ?? null,
|
||||
latestVersion,
|
||||
versions,
|
||||
screenshots,
|
||||
themePreviews: extension.themePreviews ?? [],
|
||||
createdAt: extension.createdAt ?? null,
|
||||
updatedAt: extension.updatedAt ?? null,
|
||||
...toListing(detail, state),
|
||||
longDescription: detail.longDescription ?? null,
|
||||
newestVersion: detail.newest?.version ?? null,
|
||||
newestMinAppVersion: detail.newest?.minAppVersion ?? null,
|
||||
versions: versions.map(versionRow),
|
||||
screenshots: detail.screenshots ?? [],
|
||||
themePreviews: detail.themePreviews ?? [],
|
||||
advisories: detail.advisories ?? [],
|
||||
permissionDetails: detail.permissions ?? [],
|
||||
source: detail.source,
|
||||
createdAt: detail.createdAt ?? null,
|
||||
updatedAt: detail.updatedAt ?? null,
|
||||
},
|
||||
preview: preview
|
||||
? { version: preview.version, channel: preview.channel, yanked: preview.yanked, minAppVersion: preview.minAppVersion, maxAppVersion: preview.maxAppVersion }
|
||||
: null,
|
||||
// The version an install link asked for, when the directory no
|
||||
// longer has it; the page says so instead of silently showing another.
|
||||
requestedVersionMissing: requested && !requestedRow ? requested : null,
|
||||
bundle: {
|
||||
manifest,
|
||||
source: sourcePreview,
|
||||
@@ -191,8 +195,11 @@ export async function GET(
|
||||
error: bundleError,
|
||||
signature,
|
||||
},
|
||||
installed,
|
||||
installedVersion,
|
||||
...state,
|
||||
history,
|
||||
channel,
|
||||
appVersion: APP_VERSION || null,
|
||||
directoryUrl,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn((key: string) => (key === 'extensionDirectoryUrl' ? 'https://extensions.test' : undefined)),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPluginRegistry: vi.fn(async () => ({
|
||||
plugins: [{ id: 'pretty-dates', name: 'Pretty Dates', version: '1.1.0', enabled: true, source: { kind: 'marketplace', slug: 'pretty-dates', directoryUrl: 'https://extensions.test' } }],
|
||||
})),
|
||||
getThemeRegistry: vi.fn(async () => ({ themes: [] })),
|
||||
}));
|
||||
vi.mock('@/lib/admin/extension-history', () => ({
|
||||
listHistory: vi.fn(async () => [{ version: '1.0.0', savedAt: '2026-10-10T00:00:00.000Z', contentHash: 'a'.repeat(64) }]),
|
||||
}));
|
||||
|
||||
import { GET } from '../[slug]/route';
|
||||
|
||||
const summary = {
|
||||
slug: 'pretty-dates', name: 'Pretty Dates', type: 'plugin', pluginType: 'hook', description: 'd', tags: [], license: 'MIT',
|
||||
featured: false, status: 'approved', iconUrl: null, bannerUrl: null,
|
||||
author: { login: 'o', name: 'O', avatarUrl: null, verified: true, organization: false, url: 'https://extensions.test/author/o' },
|
||||
downloads: 5, rating: { average: 4.5, count: 2 }, deprecated: null, source: { host: 'github.com', repo: 'o/r', subpath: null },
|
||||
url: 'https://extensions.test/extension/pretty-dates', createdAt: null, updatedAt: null,
|
||||
};
|
||||
const v = (version: string, extra: Record<string, unknown> = {}) => ({
|
||||
version, channel: 'stable', publishedAt: null, minAppVersion: null, maxAppVersion: null, permissions: ['email:read'], size: 10,
|
||||
sha256: 'b'.repeat(64), yanked: null, downloadUrl: '', changelog: `notes ${version}`, downloads: 1, signature: { keyId: 'k' }, ...extra,
|
||||
});
|
||||
|
||||
async function bundle(version: string) {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({ id: 'pretty-dates', name: 'Pretty Dates', version, author: 'x', description: 'd', type: 'hook', permissions: ['email:read'], entrypoint: 'index.js' }));
|
||||
zip.file('index.js', `// ${version}`);
|
||||
const buf = await zip.generateAsync({ type: 'uint8array' });
|
||||
return buf.slice().buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
function directory() {
|
||||
const versions = [v('1.2.0'), v('1.1.0'), v('1.0.0', { yanked: { at: '2026-10-01T00:00:00.000Z', reason: 'bad dates' } })];
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
const u = new URL(String(url));
|
||||
calls.push(u.pathname + u.search);
|
||||
if (u.pathname === '/api/v2/extensions/pretty-dates') {
|
||||
return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions: versions.slice(0, 2), screenshots: [], themePreviews: [], advisories: [], permissions: [] } });
|
||||
}
|
||||
if (u.pathname === '/api/v2/extensions/pretty-dates/versions') return Response.json({ data: versions });
|
||||
const m = /\/versions\/([^/]+)\/bundle$/.exec(u.pathname);
|
||||
if (m) return new Response(await bundle(decodeURIComponent(m[1])));
|
||||
return Response.json({ error: { message: 'nope' } }, { status: 404 });
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const preview = (query = '') => GET(
|
||||
new NextRequest(`http://localhost/api/admin/marketplace/pretty-dates${query}`),
|
||||
{ params: Promise.resolve({ slug: 'pretty-dates' }) },
|
||||
);
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('GET /api/admin/marketplace/[slug]', () => {
|
||||
it('previews the newest compatible version and lists every version, withdrawn ones included', async () => {
|
||||
const calls = directory();
|
||||
const res = await preview();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.preview.version).toBe('1.2.0');
|
||||
expect(body.bundle.manifest.version).toBe('1.2.0');
|
||||
expect(body.bundle.source.content).toBe('// 1.2.0');
|
||||
expect(body.extension.versions.map((x: { version: string }) => x.version)).toEqual(['1.2.0', '1.1.0', '1.0.0']);
|
||||
expect(body.extension.versions[2].yanked).toMatchObject({ reason: 'bad dates' });
|
||||
expect(body.extension.versions[0]).toMatchObject({ signed: true, changelog: 'notes 1.2.0' });
|
||||
expect(body.extension.rating).toEqual({ average: 4.5, count: 2 });
|
||||
expect(body).toMatchObject({ installed: true, installedVersion: '1.1.0', installedSource: { kind: 'marketplace' } });
|
||||
expect(body.history).toEqual([{ version: '1.0.0', savedAt: '2026-10-10T00:00:00.000Z', contentHash: 'a'.repeat(64) }]);
|
||||
expect(calls.some((c) => c.startsWith('/api/v2/extensions/pretty-dates/versions?include=yanked'))).toBe(true);
|
||||
expect(body.requestedVersionMissing).toBeNull();
|
||||
});
|
||||
|
||||
it('previews the version an install link asked for', async () => {
|
||||
directory();
|
||||
const body = await (await preview('?version=1.0.0')).json();
|
||||
expect(body.preview).toMatchObject({ version: '1.0.0', yanked: { reason: 'bad dates' } });
|
||||
expect(body.bundle.source.content).toBe('// 1.0.0');
|
||||
});
|
||||
|
||||
it('says when the requested version is gone and falls back to the newest', async () => {
|
||||
directory();
|
||||
const body = await (await preview('?version=0.9.0')).json();
|
||||
expect(body.requestedVersionMissing).toBe('0.9.0');
|
||||
expect(body.preview.version).toBe('1.2.0');
|
||||
});
|
||||
|
||||
it('refuses a malformed version instead of forwarding it', async () => {
|
||||
directory();
|
||||
expect((await preview('?version=..%2F..%2Fetc')).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import JSZip from 'jszip';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// Read when the route module loads, so it has to be set before the import.
|
||||
vi.hoisted(() => { process.env.NEXT_PUBLIC_APP_VERSION = '1.15.0'; });
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
@@ -41,6 +45,26 @@ async function bundle(manifest: Record<string, unknown>, code = 'module.exports
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory that lists one version (1.0.0 unless `meta` says otherwise) and
|
||||
* serves `bytes` for it. The route asks about the version first, then
|
||||
* downloads the bundle; both answers come from here, routed by URL.
|
||||
*/
|
||||
function directory(bytes: ArrayBuffer | Uint8Array, meta: Record<string, unknown> = {}, opts: { missing?: boolean } = {}) {
|
||||
const sha256 = createHash('sha256').update(new Uint8Array(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes))).digest('hex');
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
const u = String(url);
|
||||
calls.push(u);
|
||||
if (u.includes('/api/v2/extensions/') && u.includes('/versions/') && !u.endsWith('/bundle')) {
|
||||
if (opts.missing) return Response.json({ error: { message: 'Version not found', code: 'not_found' } }, { status: 404 });
|
||||
return Response.json({ data: { version: '1.0.0', channel: 'stable', publishedAt: null, minAppVersion: null, maxAppVersion: null, permissions: [], size: 0, sha256, yanked: null, downloadUrl: '', ...meta } });
|
||||
}
|
||||
return new Response(bytes instanceof Uint8Array ? (bytes.slice().buffer as ArrayBuffer) : bytes);
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
function install(body: Record<string, unknown>) {
|
||||
return POST(new NextRequest('http://localhost/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
@@ -62,21 +86,81 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('POST /api/admin/marketplace', () => {
|
||||
it('installs a plugin under its directory slug', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pretty-dates' }))));
|
||||
it('installs a plugin under its directory slug and records where it came from', async () => {
|
||||
const calls = directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }));
|
||||
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(registry.get('pretty-dates')).toMatchObject({
|
||||
version: '1.0.0',
|
||||
source: { kind: 'marketplace', slug: 'pretty-dates', directoryUrl: 'https://extensions.test' },
|
||||
});
|
||||
// v2 only: the version is looked up, then its bundle fetched from the same directory.
|
||||
expect(calls).toEqual([
|
||||
'https://extensions.test/api/v2/extensions/pretty-dates/versions/1.0.0',
|
||||
'https://extensions.test/api/v2/extensions/pretty-dates/versions/1.0.0/bundle',
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers 404 for a version the directory does not have', async () => {
|
||||
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), {}, { missing: true });
|
||||
const res = await install({ slug: 'pretty-dates', version: '9.9.9', type: 'plugin' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a withdrawn version unless the admin insists', async () => {
|
||||
const bytes = await bundle({ ...MANIFEST, id: 'pretty-dates' });
|
||||
directory(bytes, { yanked: { at: '2026-10-01T00:00:00.000Z', reason: 'breaks replies' } });
|
||||
|
||||
const refused = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
expect(refused.status).toBe(409);
|
||||
const body = await refused.json();
|
||||
expect(body.error).toMatch(/withdrawn.*breaks replies/);
|
||||
expect(body.yanked).toMatchObject({ reason: 'breaks replies' });
|
||||
expect(registry.size).toBe(0);
|
||||
|
||||
directory(bytes, { yanked: { at: '2026-10-01T00:00:00.000Z', reason: 'breaks replies' } });
|
||||
const forced = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', force: true });
|
||||
expect(forced.status).toBe(200);
|
||||
expect((await forced.json()).yanked).toMatchObject({ reason: 'breaks replies' });
|
||||
expect(registry.has('pretty-dates')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a version this Bulwark is too new or too old for', async () => {
|
||||
const bytes = await bundle({ ...MANIFEST, id: 'pretty-dates' });
|
||||
directory(bytes, { maxAppVersion: '1.14.0' });
|
||||
const tooNew = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
expect(tooNew.status).toBe(400);
|
||||
expect((await tooNew.json()).error).toMatch(/up to 1\.14\.0/);
|
||||
|
||||
directory(bytes, { minAppVersion: '2.0.0' });
|
||||
const tooOld = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
expect(tooOld.status).toBe(400);
|
||||
expect((await tooOld.json()).error).toMatch(/Needs Bulwark 2\.0\.0/);
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a bundle whose bytes differ from what the directory lists', async () => {
|
||||
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }), { sha256: 'f'.repeat(64) });
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
expect(res.status).toBe(502);
|
||||
expect(registry.size).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a version that is not a release version', async () => {
|
||||
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }));
|
||||
const res = await install({ slug: 'pretty-dates', version: '../../x', type: 'plugin' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('refuses a ZIP bomb from the directory', async () => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({ ...MANIFEST, id: 'pretty-dates' }));
|
||||
zip.file('index.js', new Uint8Array(30 * 1024 * 1024));
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes as BodyInit)));
|
||||
directory(bytes);
|
||||
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
|
||||
@@ -89,7 +173,7 @@ describe('POST /api/admin/marketplace', () => {
|
||||
// inheriting its enabled and force-enabled state, while the admin UI
|
||||
// (which keys install status by slug) still shows "not installed".
|
||||
registry.set('pgp-plugin', { id: 'pgp-plugin', forceEnabled: true, version: '2.0.0' });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pgp-plugin' }))));
|
||||
directory(await bundle({ ...MANIFEST, id: 'pgp-plugin' }));
|
||||
|
||||
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@ function serve(bytes: Uint8Array, headers: Record<string, string>) {
|
||||
const ringBody = { _type: 'https://extensions.bulwarkmail.org/keyring/v1', rootKeyId: root.keyId, issuedAt: '2026-10-14T00:00:00.000Z', keys: [{ keyId: online.keyId, publicKey: online.raw, status: 'active', notBefore: '2026-10-14T00:00:00.000Z', notAfter: null }] };
|
||||
const ring = { ...ringBody, signature: sign(null, Buffer.from(canonicalJson(ringBody)), root.privateKey).toString('base64') };
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
if (String(url).endsWith('/api/v2/keys')) return Response.json(ring);
|
||||
const u = String(url);
|
||||
if (u.endsWith('/api/v2/keys')) return Response.json(ring);
|
||||
// The version lookup before the download; the listing names the hash of
|
||||
// the bytes that are then served, so only the signature decides here.
|
||||
if (u.includes('/versions/') && !u.endsWith('/bundle')) {
|
||||
return Response.json({ data: { version: '1.0.0', channel: 'stable', yanked: null, minAppVersion: null, maxAppVersion: null, sha256: createHash('sha256').update(bytes).digest('hex') } });
|
||||
}
|
||||
return new Response(bytes.slice().buffer as ArrayBuffer, { headers });
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: vi.fn(async () => {}),
|
||||
get: vi.fn((key: string) => (key === 'extensionDirectoryUrl' ? 'https://extensions.test' : key === 'extensionDirectoryChannel' ? 'beta' : undefined)),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPluginRegistry: vi.fn(async () => ({
|
||||
plugins: [
|
||||
{ id: 'quick-reply', name: 'Quick Reply', version: '1.2.0', enabled: true, source: { kind: 'marketplace', slug: 'quick-reply', directoryUrl: 'https://extensions.test' } },
|
||||
{ id: 'house-plugin', name: 'House', version: '0.1.0', enabled: true, source: { kind: 'upload' } },
|
||||
// Installed before the source was recorded: still asked about.
|
||||
{ id: 'old-install', name: 'Old', version: '3.0.0', enabled: false },
|
||||
],
|
||||
})),
|
||||
getThemeRegistry: vi.fn(async () => ({
|
||||
themes: [{ id: 'night', name: 'Night', version: '2.0.0', enabled: true, source: { kind: 'marketplace', slug: 'night', directoryUrl: 'https://extensions.test' } }],
|
||||
})),
|
||||
}));
|
||||
vi.mock('@/lib/admin/extension-history', () => ({ listHistory: vi.fn(async () => []) }));
|
||||
|
||||
import { GET } from '../updates/route';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('GET /api/admin/marketplace/updates', () => {
|
||||
it('asks the directory about the marketplace installs only, on the configured channel', async () => {
|
||||
let posted: { url: string; body: unknown } | null = null;
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
||||
posted = { url: String(url), body: JSON.parse(String(init.body)) };
|
||||
return Response.json({
|
||||
data: {
|
||||
updates: [{ slug: 'quick-reply', currentVersion: '1.2.0', version: '1.3.0', channel: 'stable', changelog: 'faster' }],
|
||||
yanked: [{ slug: 'night', version: '2.0.0', reason: 'contrast', at: '2026-10-01T00:00:00.000Z' }],
|
||||
deprecated: [], suspended: [], advisories: [], unknown: ['old-install'],
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
const res = await GET(new NextRequest('http://localhost/api/admin/marketplace/updates'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
|
||||
expect(posted!.url).toBe('https://extensions.test/api/v2/updates');
|
||||
expect(posted!.body).toEqual({
|
||||
channel: 'beta',
|
||||
installed: [
|
||||
{ slug: 'quick-reply', version: '1.2.0' },
|
||||
{ slug: 'old-install', version: '3.0.0' },
|
||||
{ slug: 'night', version: '2.0.0' },
|
||||
],
|
||||
});
|
||||
expect(body.updates).toHaveLength(1);
|
||||
expect(body.yanked[0]).toMatchObject({ slug: 'night' });
|
||||
expect(body.unknown).toEqual(['old-install']);
|
||||
// Every install is listed, uploads included, for the "Installed" view.
|
||||
expect(body.installed.map((e: { id: string; kind: string }) => `${e.kind}:${e.id}`)).toEqual([
|
||||
'plugin:quick-reply', 'plugin:house-plugin', 'plugin:old-install', 'theme:night',
|
||||
]);
|
||||
expect(body.error).toBeNull();
|
||||
});
|
||||
|
||||
it('still lists the installs when the directory is unreachable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); }));
|
||||
const res = await GET(new NextRequest('http://localhost/api/admin/marketplace/updates'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.installed).toHaveLength(4);
|
||||
expect(body.updates).toEqual([]);
|
||||
expect(body.error).toMatch(/directory/i);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
saveTheme,
|
||||
getPlugin,
|
||||
getTheme,
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
themeCSSResolver,
|
||||
type ServerPlugin,
|
||||
type ServerTheme,
|
||||
@@ -16,6 +14,7 @@ import {
|
||||
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
||||
import {
|
||||
EXTENSION_ID_RE,
|
||||
EXTENSION_VERSION_RE,
|
||||
ExtensionPackageError,
|
||||
readPluginPackage,
|
||||
readThemePackage,
|
||||
@@ -23,15 +22,34 @@ import {
|
||||
} from '@/lib/extension-package';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { checkBundleSignature } from '@/lib/admin/directory-keys';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import { compareVersions } from '@/lib/version-compare';
|
||||
import { RELEASE_VERSION } from '@/lib/release-version';
|
||||
import {
|
||||
APP_VERSION,
|
||||
DirectoryError,
|
||||
compatQuery,
|
||||
directoryJson,
|
||||
getDirectoryChannel,
|
||||
getDirectoryUrl,
|
||||
installedExtensions,
|
||||
installedStateOf,
|
||||
toListing,
|
||||
v2Url,
|
||||
type DirectorySummary,
|
||||
type DirectoryVersion,
|
||||
} from '@/lib/admin/marketplace-directory';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
}
|
||||
/** Query parameters the UI may pass on to the directory's list endpoint. */
|
||||
const FORWARDED = ['q', 'type', 'pluginType', 'tag', 'author', 'sort', 'order', 'page', 'perPage', 'featured'] as const;
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
* Proxies to the extension directory API
|
||||
* GET /api/admin/marketplace - Browse the extension directory
|
||||
*
|
||||
* Proxies the directory's `/api/v2/extensions` with this Bulwark's version
|
||||
* and channel, so the version offered for each extension is one this
|
||||
* install can run, and marks what is already installed.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -39,78 +57,47 @@ export async function GET(request: NextRequest) {
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const { searchParams } = request.nextUrl;
|
||||
const url = new URL('/api/v1/extensions', directoryUrl);
|
||||
|
||||
// Forward all search params
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
// The directory then offers the newest version this install can run
|
||||
// (and pre-releases only when the admin opted into the beta channel).
|
||||
if (process.env.NEXT_PUBLIC_APP_VERSION) url.searchParams.set('appVersion', process.env.NEXT_PUBLIC_APP_VERSION);
|
||||
url.searchParams.set('channel', configManager.get<string>('extensionDirectoryChannel') === 'beta' ? 'beta' : 'stable');
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch from extension directory' },
|
||||
{ status: 502 }
|
||||
);
|
||||
const query: Record<string, string | undefined> = {};
|
||||
for (const key of FORWARDED) {
|
||||
const value = request.nextUrl.searchParams.get(key);
|
||||
if (value) query[key] = value;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Enrich with install status
|
||||
const [pluginRegistry, themeRegistry] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
|
||||
const installedPluginVersions = new Map(
|
||||
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
|
||||
const data = await directoryJson<{ data: DirectorySummary[]; meta: { page: number; perPage: number; total: number } }>(
|
||||
v2Url(directoryUrl, '/extensions', { ...query, ...compatQuery() }),
|
||||
);
|
||||
const installedThemeVersions = new Map(
|
||||
themeRegistry.themes.map(t => [t.id, t.version] as const),
|
||||
const installed = await installedExtensions();
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: (data.data ?? []).map((ext) => toListing(ext, installedStateOf(installed, ext.slug, ext.type))),
|
||||
meta: data.meta,
|
||||
channel: getDirectoryChannel(),
|
||||
appVersion: APP_VERSION || null,
|
||||
directoryUrl,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
if (data.data) {
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => {
|
||||
const slug = ext.slug as string;
|
||||
const installedVersion = ext.type === 'theme'
|
||||
? installedThemeVersions.get(slug) ?? null
|
||||
: installedPluginVersions.get(slug) ?? null;
|
||||
return {
|
||||
...ext,
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: installedVersion !== null,
|
||||
installedVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DirectoryError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||
}
|
||||
logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/marketplace - Install an extension from the directory
|
||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||
* POST /api/admin/marketplace - Install one version of an extension
|
||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme', force?: boolean }
|
||||
*
|
||||
* The version is looked up on the directory first. A version the publisher
|
||||
* withdrew ("yanked") is refused with 409 unless `force` is set - the admin
|
||||
* UI asks before sending that. A version this Bulwark is too old or too new
|
||||
* for is refused outright. Then the bundle is downloaded, its directory
|
||||
* signature checked, and it is installed under the directory slug, keeping
|
||||
* the enabled and forced flags of whatever was installed before. The replaced
|
||||
* version stays on disk for a rollback (lib/admin/extension-history.ts).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -118,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slug, version, type } = await request.json();
|
||||
const { slug, version, type, force } = await request.json();
|
||||
|
||||
if (!slug || !version || !type) {
|
||||
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
|
||||
@@ -128,6 +115,31 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// The version goes into a URL, the registry and the history directory
|
||||
// name; anything but a version string breaks the next update of the
|
||||
// extension (dirNameFor throws on a non-string) and is echoed to the UI.
|
||||
if (typeof version !== 'string' || !EXTENSION_VERSION_RE.test(version)) {
|
||||
return NextResponse.json({ error: 'Invalid version: must look like 1.2.3' }, { status: 400 });
|
||||
}
|
||||
|
||||
// An older release than the installed one is refused unless the admin
|
||||
// insists (`force`, the same confirm step as for a withdrawn version):
|
||||
// a directory, or someone in the path to it, that lists an old,
|
||||
// vulnerable release as the latest must not have an "update" quietly
|
||||
// install it.
|
||||
if (force !== true) {
|
||||
const installed = type === 'theme' ? await getTheme(slug) : await getPlugin(slug);
|
||||
if (installed && EXTENSION_VERSION_RE.test(installed.version) && compareVersions(version, installed.version) < 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Version ${version} is older than the installed ${installed.version}.`,
|
||||
downgrade: { installedVersion: installed.version },
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The slug becomes a file name and the registry key; it must be a plain id.
|
||||
if (typeof slug !== 'string' || !EXTENSION_ID_RE.test(slug)) {
|
||||
return NextResponse.json(
|
||||
@@ -135,11 +147,50 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (typeof version !== 'string' || !RELEASE_VERSION.test(version)) {
|
||||
return NextResponse.json({ error: 'Invalid version: must look like 1.2.3' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Download the bundle from the directory
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
const versionPath = `/extensions/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
|
||||
|
||||
// 1. What the directory says about this version.
|
||||
let meta: DirectoryVersion;
|
||||
try {
|
||||
meta = (await directoryJson<{ data: DirectoryVersion }>(v2Url(directoryUrl, versionPath))).data;
|
||||
} catch (err) {
|
||||
if (err instanceof DirectoryError && err.status === 404) {
|
||||
return NextResponse.json({ error: `Version ${version} of "${slug}" is not in the directory` }, { status: 404 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (meta.yanked && force !== true) {
|
||||
const reason = meta.yanked.reason ? `: ${meta.yanked.reason}` : '';
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Version ${version} was withdrawn by its publisher${reason}. Pick another version, or install it anyway.`,
|
||||
yanked: meta.yanked,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (APP_VERSION && meta.minAppVersion && compareVersions(APP_VERSION, meta.minAppVersion) < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Needs Bulwark ${meta.minAppVersion} or later (this is ${APP_VERSION})` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (APP_VERSION && meta.maxAppVersion && compareVersions(APP_VERSION, meta.maxAppVersion) > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Version ${version} supports Bulwark up to ${meta.maxAppVersion} (this is ${APP_VERSION})` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 2. The bundle itself, from the same directory (never from a URL the
|
||||
// directory handed back).
|
||||
const bundleRes = await fetch(v2Url(directoryUrl, `${versionPath}/bundle`).toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
@@ -168,6 +219,18 @@ export async function POST(request: NextRequest) {
|
||||
logger.warn('Marketplace bundle refused: signature check failed', { slug, version, reason: signature.reason });
|
||||
return NextResponse.json({ error: `Refused: ${signature.reason}` }, { status: 400 });
|
||||
}
|
||||
// Without root keys (a self-hosted directory) the listing's hash is the
|
||||
// only check that the bytes are the ones the directory meant.
|
||||
if (meta.sha256 && createHash('sha256').update(new Uint8Array(buffer)).digest('hex') !== meta.sha256) {
|
||||
return NextResponse.json({ error: 'Refused: the downloaded bundle does not match what the directory lists' }, { status: 502 });
|
||||
}
|
||||
|
||||
const source = { kind: 'marketplace' as const, slug, directoryUrl };
|
||||
const installExtras = {
|
||||
signature: signature.status,
|
||||
channel: meta.channel,
|
||||
yanked: meta.yanked,
|
||||
};
|
||||
|
||||
if (type === 'theme') {
|
||||
const read = await readThemePackage(buffer, { resolveExtends: await themeCSSResolver() });
|
||||
@@ -208,6 +271,7 @@ export async function POST(request: NextRequest) {
|
||||
: {}),
|
||||
installedAt: existingTheme?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
source,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css, skin);
|
||||
@@ -219,12 +283,13 @@ export async function POST(request: NextRequest) {
|
||||
version: theme.version,
|
||||
slug,
|
||||
signature: signature.status,
|
||||
...(meta.yanked ? { yanked: true } : {}),
|
||||
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate, signature: signature.status });
|
||||
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate, previousVersion: existingTheme?.version ?? null, ...installExtras });
|
||||
}
|
||||
|
||||
// Plugin installation
|
||||
@@ -281,6 +346,7 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}),
|
||||
...(manifest.locales ? { locales: manifest.locales } : {}),
|
||||
...(manifest.credentials ? { credentials: manifest.credentials } : {}),
|
||||
source,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
@@ -296,13 +362,19 @@ export async function POST(request: NextRequest) {
|
||||
httpOrigins: plugin.httpOrigins ?? [],
|
||||
apiPostPaths: plugin.apiPostPaths ?? [],
|
||||
signature: signature.status,
|
||||
...(meta.yanked ? { yanked: true } : {}),
|
||||
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate, signature: signature.status });
|
||||
return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate, previousVersion: existingPlugin?.version ?? null, ...installExtras });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
if (error instanceof DirectoryError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||
}
|
||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
APP_VERSION,
|
||||
DirectoryError,
|
||||
askDirectoryAbout,
|
||||
directoryJson,
|
||||
getDirectoryChannel,
|
||||
getDirectoryUrl,
|
||||
installedExtensions,
|
||||
v2Url,
|
||||
type DirectoryAdvisory,
|
||||
type DirectoryVersion,
|
||||
} from '@/lib/admin/marketplace-directory';
|
||||
|
||||
export interface UpdatesAnswer {
|
||||
updates: Array<DirectoryVersion & { slug: string; currentVersion: string; 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: DirectoryAdvisory[];
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
const EMPTY: UpdatesAnswer = { updates: [], yanked: [], deprecated: [], suspended: [], advisories: [], unknown: [] };
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace/updates
|
||||
*
|
||||
* Asks the directory about every installed extension that came from it (or
|
||||
* predates the record of where it came from): newer versions this Bulwark
|
||||
* can run, versions the publisher withdrew, deprecations, suspensions and
|
||||
* security advisories. Also lists what is installed, so the marketplace can
|
||||
* show the "Installed" view from one request.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const installed = await installedExtensions();
|
||||
const list = [...installed].map(([id, e]) => ({ id, ...e }));
|
||||
const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version }));
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const channel = getDirectoryChannel();
|
||||
let answer: UpdatesAnswer = EMPTY;
|
||||
let error: string | null = null;
|
||||
if (ask.length > 0) {
|
||||
try {
|
||||
const res = await directoryJson<{ data: UpdatesAnswer }>(v2Url(directoryUrl, '/updates'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appVersion: APP_VERSION || undefined, channel, installed: ask }),
|
||||
});
|
||||
answer = { ...EMPTY, ...res.data };
|
||||
} catch (err) {
|
||||
// The installed list is still useful without the directory; say what
|
||||
// went wrong instead of failing the whole view.
|
||||
error = err instanceof DirectoryError ? err.message : 'Could not reach the extension directory';
|
||||
logger.warn('Marketplace updates check failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ installed: list, ...answer, directoryUrl, channel, appVersion: APP_VERSION || null, error },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Marketplace updates error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Failed to check for updates' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
let readOnly = false;
|
||||
let saveError: Error | null = null;
|
||||
const jmapCalls: Array<[string, Record<string, unknown>]> = [];
|
||||
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => ({ ok: true }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: async () => ({
|
||||
serverUrl: 'https://mail.example.org',
|
||||
authHeader: 'Basic x',
|
||||
trusted: true,
|
||||
username: 'admin@example.org',
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/lib/admin/paths', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/admin/paths')>()),
|
||||
isConfigReadOnly: () => readOnly,
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
setAdminConfig: async () => { if (saveError) throw saveError; },
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
|
||||
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
||||
fetchJmapServer: async (url: string, init: { body?: string }) => {
|
||||
if (url.endsWith('/.well-known/jmap')) {
|
||||
return Response.json({ primaryAccounts: { 'urn:stalwart:jmap': 'a' } });
|
||||
}
|
||||
const [name, args] = JSON.parse(init.body!).methodCalls[0];
|
||||
jmapCalls.push([name, args]);
|
||||
if (name === 'x:OAuthClient/query') return Response.json({ methodResponses: [[name, { ids: [] }, '0']] });
|
||||
return Response.json({ methodResponses: [[name, { created: { new: { id: 'h' } } }, '0']] });
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
|
||||
function setup() {
|
||||
return POST(new NextRequest('http://localhost/api/admin/oauth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ origin: 'https://webmail.example.org', issuerUrl: 'https://mail.example.org' }),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
readOnly = false;
|
||||
saveError = null;
|
||||
jmapCalls.length = 0;
|
||||
});
|
||||
|
||||
describe('POST /api/admin/oauth/setup', () => {
|
||||
it('creates the client on Stalwart when the config is writable', async () => {
|
||||
const res = await setup();
|
||||
expect(res.status).toBe(200);
|
||||
expect(jmapCalls.map(([name]) => name)).toEqual(['x:OAuthClient/query', 'x:OAuthClient/set']);
|
||||
});
|
||||
|
||||
it('refuses in read-only mode without sending anything to Stalwart (#1250)', async () => {
|
||||
readOnly = true;
|
||||
const res = await setup();
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).error).toMatch(/read-only/);
|
||||
expect(jmapCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys the client it just created when saving the config fails', async () => {
|
||||
saveError = new Error('EROFS');
|
||||
const res = await setup();
|
||||
expect(res.status).toBe(500);
|
||||
expect(jmapCalls.at(-1)).toEqual(['x:OAuthClient/set', { accountId: 'a', destroy: ['h'] }]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isConfigReadOnly, ConfigReadOnlyError } from '@/lib/admin/paths';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { locales as ALL_LOCALES } from '@/i18n/routing';
|
||||
@@ -90,6 +91,16 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
// The setup ends by saving the client secret here. Refuse before
|
||||
// touching Stalwart, or a read-only config leaves an orphaned client
|
||||
// (or a rotated secret nobody stored) on the server.
|
||||
if (isConfigReadOnly()) {
|
||||
return NextResponse.json(
|
||||
{ error: new ConfigReadOnlyError('set up OAuth').message },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
@@ -195,6 +206,7 @@ export async function POST(request: NextRequest) {
|
||||
}, { status: 403 });
|
||||
}
|
||||
const setBody = setEntry[1] as {
|
||||
created?: Record<string, { id?: string }>;
|
||||
notCreated?: Record<string, unknown>;
|
||||
notUpdated?: Record<string, unknown>;
|
||||
};
|
||||
@@ -219,7 +231,27 @@ export async function POST(request: NextRequest) {
|
||||
oauthIssuerUrl: issuerUrl,
|
||||
};
|
||||
if (oauthOnly) updates.oauthOnly = true;
|
||||
await configManager.setAdminConfig(updates);
|
||||
try {
|
||||
await configManager.setAdminConfig(updates);
|
||||
} catch (error) {
|
||||
// Don't leave a client behind that this install never stored. An
|
||||
// updated client can't be restored: Stalwart doesn't hand back the
|
||||
// old secret.
|
||||
if (action === 'created') {
|
||||
const createdId = setBody.created?.new?.id;
|
||||
if (createdId) {
|
||||
await jmapCall(creds, {
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [['x:OAuthClient/set', { accountId, destroy: [createdId] }, '0']],
|
||||
}).catch((rollbackError) => {
|
||||
logger.error('Admin OAuth setup rollback failed', {
|
||||
error: rollbackError instanceof Error ? rollbackError.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await auditLog('admin.oauth_setup', {
|
||||
action,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/**
|
||||
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
|
||||
@@ -55,6 +56,8 @@ export async function POST(request: NextRequest) {
|
||||
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
|
||||
return NextResponse.json({ entry });
|
||||
} catch (err) {
|
||||
const readOnly = configReadOnlyResponse(err);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -79,6 +82,8 @@ export async function DELETE(request: NextRequest) {
|
||||
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const readOnly = configReadOnlyResponse(err);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDevPlugin } from '@/lib/admin/plugin-dev';
|
||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
|
||||
async function resolvePlugin(id: string) {
|
||||
@@ -128,7 +129,9 @@ export async function PUT(
|
||||
|
||||
await setPluginConfig(id, body.key, body.value);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -165,7 +168,9 @@ export async function DELETE(
|
||||
|
||||
await deletePluginConfigKey(id, body.key);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
|
||||
import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup';
|
||||
import { listHistory } from '@/lib/admin/extension-history';
|
||||
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
|
||||
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
@@ -31,10 +33,15 @@ export async function GET(request: NextRequest) {
|
||||
// Dev plugins win on id collision so admins see what users actually load.
|
||||
const devIds = new Set(devEntries.map(e => e.plugin.id));
|
||||
const merged = [
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const })),
|
||||
...registry.plugins
|
||||
...devEntries.map(e => ({ ...e.plugin, dev: true as const, previousVersions: [] as string[] })),
|
||||
...(await Promise.all(registry.plugins
|
||||
.filter(p => !devIds.has(p.id))
|
||||
.map(p => ({ ...p, dev: false as const })),
|
||||
.map(async p => ({
|
||||
...p,
|
||||
dev: false as const,
|
||||
// Versions this server still has a copy of (lib/admin/extension-history.ts).
|
||||
previousVersions: (await listHistory('plugin', p.id).catch(() => [])).map(h => h.version),
|
||||
})))),
|
||||
];
|
||||
return NextResponse.json(merged, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
@@ -113,6 +120,7 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.credentials ? { credentials: manifest.credentials } : {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
source: { kind: 'upload' },
|
||||
};
|
||||
const declaredFrameOrigins = plugin.frameOrigins ?? [];
|
||||
const declaredHttpOrigins = plugin.httpOrigins ?? [];
|
||||
@@ -124,6 +132,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ plugin, warnings: pkg.warnings });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -175,6 +185,8 @@ export async function PATCH(request: NextRequest) {
|
||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ plugin: updated });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -206,6 +218,8 @@ export async function DELETE(request: NextRequest) {
|
||||
await auditLog('plugin.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/**
|
||||
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this;
|
||||
@@ -62,6 +63,9 @@ export async function PUT(request: NextRequest) {
|
||||
if (policy.features && typeof policy.features !== 'object') {
|
||||
return NextResponse.json({ error: 'features must be an object' }, { status: 400 });
|
||||
}
|
||||
if (policy.defaults !== undefined && (typeof policy.defaults !== 'object' || policy.defaults === null || Array.isArray(policy.defaults))) {
|
||||
return NextResponse.json({ error: 'defaults must be an object' }, { status: 400 });
|
||||
}
|
||||
if (policy.themePolicy && typeof policy.themePolicy !== 'object') {
|
||||
return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 });
|
||||
}
|
||||
@@ -78,6 +82,8 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup';
|
||||
import { listHistory } from '@/lib/admin/extension-history';
|
||||
|
||||
import { readThemePackage } from '@/lib/extension-package';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
@@ -23,7 +25,12 @@ export async function GET(request: NextRequest) {
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getThemeRegistry();
|
||||
return NextResponse.json(registry.themes, {
|
||||
const themes = await Promise.all(registry.themes.map(async t => ({
|
||||
...t,
|
||||
// Versions this server still has a copy of (lib/admin/extension-history.ts).
|
||||
previousVersions: (await listHistory('theme', t.id).catch(() => [])).map(h => h.version),
|
||||
})));
|
||||
return NextResponse.json(themes, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -69,6 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}),
|
||||
installedAt: existing?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
source: { kind: 'upload' },
|
||||
};
|
||||
|
||||
await saveTheme(theme, css, skin);
|
||||
@@ -76,6 +84,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ theme, warnings });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -113,6 +123,8 @@ export async function PATCH(request: NextRequest) {
|
||||
await auditLog('theme.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ theme: updated });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
@@ -143,6 +155,8 @@ export async function DELETE(request: NextRequest) {
|
||||
await auditLog('theme.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const readOnly = configReadOnlyResponse(error);
|
||||
if (readOnly) return readOnly;
|
||||
logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession, sessionAuthHeader, type SessionCredentials } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { JmapAuthVerificationError, verifyJmapIdentity } from '@/lib/auth/verify-jmap-auth';
|
||||
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
|
||||
import {
|
||||
clearStalwartAuthContextInStore,
|
||||
setStalwartAuthContextInStore,
|
||||
@@ -21,6 +21,8 @@ import { revokeImpersonationCredential } from '@/lib/impersonation/app-password'
|
||||
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
|
||||
import { revokeWopiTokens } from '@/lib/wopi/revocation';
|
||||
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
@@ -132,9 +134,34 @@ export async function POST(request: NextRequest) {
|
||||
// server, such as settings sync. Skipping the check for admin-configured
|
||||
// servers let anyone mint a cookie for any username with a made-up
|
||||
// password. `trusted` only relaxes the public-address requirement.
|
||||
const normalizedServerUrl = await verifyJmapIdentity(upstreamUrl, authHeader, username, {
|
||||
trusted: upstreamTrusted,
|
||||
});
|
||||
// Wrong credentials tried from here count against this server's address
|
||||
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
|
||||
const clientIp = loginClientAddress(request);
|
||||
const refund = reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' },
|
||||
{ status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } },
|
||||
);
|
||||
}
|
||||
// The context cookie also records which account the credential belongs
|
||||
// to: a Bearer login may claim a bare local part (`john`) that several
|
||||
// accounts on a multi-domain server answer to, and per-user data such as
|
||||
// synced settings must be keyed on the account, not on the claim.
|
||||
let normalizedServerUrl: string;
|
||||
let accountName: string;
|
||||
try {
|
||||
({ serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, {
|
||||
trusted: upstreamTrusted,
|
||||
clientIp,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Only a credential the server rejected spends the budget; an outage
|
||||
// or a mismatched claim says nothing about the password.
|
||||
if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund();
|
||||
throw error;
|
||||
}
|
||||
refund();
|
||||
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
@@ -142,6 +169,7 @@ export async function POST(request: NextRequest) {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
authHeader,
|
||||
...(accountName !== username ? { accountName } : {}),
|
||||
});
|
||||
|
||||
void recordLogin(username, normalizedServerUrl);
|
||||
|
||||
@@ -11,15 +11,15 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||
// this prefix may flow through the mobile handoff path; without the guard
|
||||
// the SSO complete route would be coerced into returning tokens to whatever
|
||||
// Only the mobile app's own callback link may flow through the mobile
|
||||
// handoff path (lib/auth/mobile-handoff.ts); without the guard the SSO
|
||||
// complete route would be coerced into returning tokens to whatever
|
||||
// caller-controlled URL the attacker chose.
|
||||
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||
|
||||
/**
|
||||
* A re-auth mints the grant a paired phone keeps using. Providers such as
|
||||
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
|
||||
: null;
|
||||
const mobileState =
|
||||
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
|
||||
if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
|
||||
@@ -71,9 +73,30 @@ export async function POST(request: NextRequest) {
|
||||
// any user's synced settings. Admin-configured servers are `trusted`,
|
||||
// which only relaxes the public-address requirement (they may live on a
|
||||
// private network) - never the credential check.
|
||||
const { serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, {
|
||||
trusted: upstreamTrusted,
|
||||
});
|
||||
// Wrong credentials tried from here count against this server's address
|
||||
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
|
||||
const clientIp = loginClientAddress(request);
|
||||
const refund = reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' },
|
||||
{ status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } },
|
||||
);
|
||||
}
|
||||
let normalizedServerUrl: string;
|
||||
let accountName: string;
|
||||
try {
|
||||
({ serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, {
|
||||
trusted: upstreamTrusted,
|
||||
clientIp,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Only a credential the server rejected spends the budget; an outage
|
||||
// or a mismatched claim says nothing about the password.
|
||||
if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund();
|
||||
throw error;
|
||||
}
|
||||
refund();
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
|
||||
@@ -70,17 +70,26 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider logout URL for a slot being signed out, or null when there is none to visit. */
|
||||
/**
|
||||
* The provider logout URL for a slot being signed out, or null when there is
|
||||
* none to visit. A logout URL the admin configured (#1279) goes before the
|
||||
* discovered one: it is there for providers that advertise none, such as
|
||||
* Stalwart, and for a logout page shared with other apps, which must keep
|
||||
* working once the provider starts advertising its own.
|
||||
*/
|
||||
function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null {
|
||||
if (!metadata?.end_session_endpoint || !isEndSessionEnabled()) return null;
|
||||
if (!isEndSessionEnabled()) return null;
|
||||
let clientId: string;
|
||||
let configuredEndpoint: string | undefined;
|
||||
try {
|
||||
({ clientId } = getRequiredConfig(serverId));
|
||||
({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const endpoint = configuredEndpoint || metadata?.end_session_endpoint;
|
||||
if (!endpoint) return null;
|
||||
return buildEndSessionUrl({
|
||||
endpoint: metadata.end_session_endpoint,
|
||||
endpoint,
|
||||
clientId,
|
||||
idToken,
|
||||
postLogoutRedirectUri: getPostLogoutRedirectUri(),
|
||||
@@ -265,7 +274,7 @@ export async function PUT(request: NextRequest) {
|
||||
/**
|
||||
* Revoke a slot's refresh token and clear its token cookies. With
|
||||
* `endSession`, also returns the URL that ends the provider's session, or null
|
||||
* when the provider has no usable end_session_endpoint.
|
||||
* when there is no usable logout URL.
|
||||
*/
|
||||
async function signOutSlot(
|
||||
cookieStore: CookieStore,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
|
||||
/**
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
@@ -35,6 +37,7 @@ async function attemptLogin(
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
upstreamTrusted: boolean,
|
||||
clientIp: string,
|
||||
): Promise<NextResponse> {
|
||||
const base = upstreamUrl.replace(/\/+$/, '');
|
||||
|
||||
@@ -52,6 +55,15 @@ async function attemptLogin(
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||
|| '';
|
||||
|
||||
// Wrong passwords tried from here count against this server's address
|
||||
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
|
||||
const refund = reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts' },
|
||||
{ status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } },
|
||||
);
|
||||
}
|
||||
const result = await stalwartPasswordLogin({
|
||||
serverUrl: base,
|
||||
username,
|
||||
@@ -61,7 +73,12 @@ async function attemptLogin(
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
trusted: upstreamTrusted,
|
||||
clientIp,
|
||||
});
|
||||
// Only a rejected password spends the budget. `totp_required` means the
|
||||
// password was right and the code is still missing, which every TOTP login
|
||||
// goes through once; outages say nothing about the password.
|
||||
if (result.ok || result.error !== 'invalid_credentials') refund();
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.error) {
|
||||
@@ -160,7 +177,17 @@ export async function POST(request: NextRequest) {
|
||||
? bodyRedirectUri
|
||||
: trimUrl(upstreamUrl);
|
||||
|
||||
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId, upstreamTrusted);
|
||||
return await attemptLogin(
|
||||
upstreamUrl,
|
||||
username,
|
||||
password,
|
||||
totpCode,
|
||||
redirectUri,
|
||||
slot,
|
||||
resolvedServerId,
|
||||
upstreamTrusted,
|
||||
loginClientAddress(request),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -82,14 +82,15 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Wrong passwords tried from here count against this server's address
|
||||
// upstream; over budget the browser probes the JMAP server itself.
|
||||
const refund = reserveVerifyProbe(getClientIP(request));
|
||||
const clientIp = getClientIP(request);
|
||||
const refund = reserveVerifyProbe(clientIp);
|
||||
if (!refund) return respond('inconclusive');
|
||||
|
||||
const authHeader = hasToken
|
||||
? `Bearer ${token}`
|
||||
: 'Basic ' + Buffer.from(username + ':' + password).toString('base64');
|
||||
try {
|
||||
await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
|
||||
await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted, clientIp });
|
||||
refund();
|
||||
return respond('ok');
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,10 @@ interface DiscoveryResult {
|
||||
resolvedAccount: string | null;
|
||||
}
|
||||
|
||||
/** Bounds on the fan-out of one request (each candidate is a PROPFIND upstream). */
|
||||
const MAX_ACCOUNTS = 10;
|
||||
const MAX_CANDIDATES_PER_ACCOUNT = 10;
|
||||
|
||||
function buildPublicUrl(serverUrl: string, path: string): string {
|
||||
return new URL(path, serverUrl).toString();
|
||||
}
|
||||
@@ -68,7 +72,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const accounts = Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : [];
|
||||
// Each candidate is a PROPFIND against the user's server; the lists are
|
||||
// bounded so one request cannot fan out into hundreds of them.
|
||||
const accounts = (Array.isArray(body.accounts) ? body.accounts as DiscoveryAccountRequest[] : []).slice(0, MAX_ACCOUNTS);
|
||||
const wellKnownUrl = buildPublicUrl(creds.serverUrl, '/.well-known/caldav');
|
||||
const discovered: Record<string, DiscoveryResult> = {};
|
||||
|
||||
@@ -76,10 +82,10 @@ export async function POST(request: NextRequest) {
|
||||
if (!account?.key) continue;
|
||||
|
||||
const candidates = Array.from(new Set(
|
||||
(account.candidates || [])
|
||||
.map((candidate) => candidate?.trim())
|
||||
(Array.isArray(account.candidates) ? account.candidates : [])
|
||||
.map((candidate) => (typeof candidate === 'string' ? candidate.trim() : ''))
|
||||
.filter((candidate): candidate is string => Boolean(candidate))
|
||||
));
|
||||
)).slice(0, MAX_CANDIDATES_PER_ACCOUNT);
|
||||
|
||||
let url: string | null = null;
|
||||
let resolvedAccount: string | null = null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user