diff --git a/.dockerignore b/.dockerignore index b6764a50f..68da62e5b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,9 @@ lite-image !.env.dev.example scripts/ !scripts/lite/ +!scripts/build-plugin-sandbox.mjs +# Built in the image by scripts/build-plugin-sandbox.mjs +public/plugin-sandbox-assets/ TODO.md *.md !README.md diff --git a/.env.example b/.env.example index 62f5e4769..36a3c2002 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,8 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Prefer configuring this from the admin dashboard - the env form exists for # stateless deployments. An "oauth" block with both "clientId" and # "buttonLabel" gives that server its own sign-in button on the login page, -# even while OAuth is off globally. +# even while OAuth is off globally. "endSessionUrl" in the "oauth" block is +# that server's OAUTH_END_SESSION_URL (see below). # JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}] # Pick the server automatically from the domain of the address the user types, @@ -80,10 +81,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com # OAUTH_ALLOW_PRIVATE_ENDPOINTS=true # Signing out of an SSO account also signs out of the identity provider, -# through the end_session_endpoint it advertises (RP-initiated logout). Set to -# false when the provider is shared with apps that should stay signed in. +# through the end_session_endpoint it advertises or OAUTH_END_SESSION_URL +# (RP-initiated logout). Set to false when the provider is shared with apps +# that should stay signed in. # OAUTH_END_SESSION=false +# The provider's logout URL, for providers whose discovery document has no +# end_session_endpoint (Stalwart's built-in provider, for example). When set, +# it is used instead of the discovered endpoint and gets the same parameters: +# client_id, id_token_hint and post_logout_redirect_uri. Must be https. +# Applies to OAUTH_ISSUER_URL's provider only; JMAP_SERVERS entries set their +# own "endSessionUrl" in their "oauth" block. +# OAUTH_END_SESSION_URL=https://your-idp.example.com/logout + # Where the identity provider sends the browser after signing the user out. # Register it with the provider as a post-logout redirect URI first, or it # will refuse the logout. Unset: the provider shows its own signed-out page. @@ -418,8 +428,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # COOKIE_SAME_SITE=none. Set to false only for local HTTP development. # COOKIE_SECURE=false -# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none', -# which blocks all framing. Space-separate multiple origins. +# Which other sites may frame the app, as a CSP frame-ancestors value. By +# default none may. Space-separate multiple origins. 'self' is always added, +# since the app frames its own PDF previews. # ALLOWED_FRAME_ANCESTORS=https://portal.example.com # Origin of the parent page when embedded, used for postMessage handshakes. diff --git a/.gitattributes b/.gitattributes index 2b543109f..c9944983b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ # Sourced by /bin/sh inside the Lite container image: CRLF would break it. *.envsh text eol=lf +# Run by sh from Git Bash on Windows, which does not strip CR. +.husky/* text eol=lf +# Demo fixtures record exact byte sizes of these files; never normalise them. +public/demo/** -text diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a7bae41ac..d714a6a87 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,6 +21,10 @@ on: - ".github/workflows/docker-publish.yml" workflow_dispatch: +# Jobs that need more (the registry push, the attestation) say so themselves. +permissions: + contents: read + jobs: prepare: name: Prepare diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml index 35ecb88ee..4336d783c 100644 --- a/.github/workflows/perf-guard.yml +++ b/.github/workflows/perf-guard.yml @@ -9,6 +9,11 @@ on: push: pull_request: +# The job builds and runs the checked-out code; a compromised step must not +# hold a token that can write to the repository. +permissions: + contents: read + jobs: perf-guard: name: Perf guard @@ -36,7 +41,7 @@ jobs: DEV_MOCK_JMAP: "true" NEXT_TELEMETRY_DISABLED: "1" GIT_COMMIT: ${{ github.sha }} - run: npx next build --webpack + run: node scripts/build-plugin-sandbox.mjs && npx next build --webpack - name: Run perf guard env: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 43a7acac3..002bd8a76 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,11 @@ on: push: pull_request: +# The job builds and runs the checked-out code; a compromised step must not +# hold a token that can write to the repository. +permissions: + contents: read + jobs: vitest: name: Vitest @@ -29,3 +34,28 @@ jobs: # from timing out on the shared runners; the longer per-test timeout # covers the jsdom-heavy suites. run: npx vitest run --pool=forks --maxWorkers=2 --testTimeout=15000 + + # The pre-commit hook lints only staged files and never runs on fork PRs, + # so this is the one place the whole tree is checked. + lint: + name: Lint and typecheck + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint diff --git a/.gitignore b/.gitignore index 276b85604..dd9bc4281 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ /.next/ /out/ /lite-image/ +# scripts/build-plugin-sandbox.mjs +/public/plugin-sandbox-assets/ # production /build diff --git a/.husky/pre-commit b/.husky/pre-commit index 58f8aa205..5b09e6120 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,6 @@ -npx tsc --noEmit && npx eslint . --ext .ts,.tsx +#!/bin/sh +# Typecheck the project (incremental, a few seconds warm) and lint only the +# staged files. The full lint and typecheck run in CI for every push and PR. +npx tsc --noEmit || exit 1 +files=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx' '*.js' '*.mjs') +[ -z "$files" ] || echo "$files" | xargs npx eslint --no-warn-ignored diff --git a/CHANGELOG.md b/CHANGELOG.md index 642b7147e..bc8b88c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,317 @@ # Changelog +## 1.17.2 (2026-10-24) + +1.17.2 fixes a cross-site scripting hole in re-opened drafts and keeps Safari from dropping push subscriptions after pushes that had nothing new to show. Drafts written as a group identity now stay in the group's Drafts, and bulk delete, trash and move with several accounts on different servers act on the right server. Plugins can update and remove their own tags, format dates in the user's regional settings and put buttons beside a sidebar section's title. User-facing texts use the configured app name instead of "Bulwark". + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- [@bultester](https://github.com/bultester) +- Anonymous +- Anonymous +- Anonymous + +**Monthly** + +- Anonymous +- [@superspacehosting](https://github.com/superspacehosting) +- [@naokton](https://github.com/naokton) +- [@yerTools](https://github.com/yerTools) +- [@jsaathof](https://github.com/jsaathof) +- [@berkersal](https://github.com/berkersal) +- [@NABarnes](https://github.com/NABarnes) +- [@felixzieger](https://github.com/felixzieger) +- [@pr0ton11](https://github.com/pr0ton11) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) +- [@HiltMundell](https://github.com/HiltMundell) + +### Security + +- **Composer**: The quoted original and the signature in a re-opened draft are sanitized before they render. A draft written by another client, or by another member of a shared mailbox, could carry markup that ran script in the webmail when you opened it (GHSA-g35q-3gpc-x2rr, thanks @muhammad-zeeshan1337) + +### Features + +- **Plugins**: Plugins can change the label, colour and visibility of the tags they added and remove them again with `keywords.update` and `keywords.remove`. Both need `settings:write` and never touch the user's own tags or another plugin's (#1333, thanks @HOLOEYE-JZ) +- **Plugins**: Plugin frames get the user's date locale, 12/24-hour setting, time zone and first day of the week, and can format with `api.i18n.formatDate` and `api.i18n.formatTime`, without asking for `settings:read` (#1335, thanks @tobiasge) +- **Plugins**: A new `sidebar-section-actions` slot puts a plugin's buttons beside its sidebar section's title instead of in a row below it (#1317, thanks @LeonardNJU) +- **Admin**: A policy switch lists the Tags above the shared mailboxes in the sidebar, so they stay in sight when many shared mailboxes are expanded. It is off by default (#1334, thanks @HOLOEYE-JZ) +- **Branding**: Default apps in Settings, the "Open Bulwark" notification, the rate-limit banner, the plugin connect dialog and the connector page use the configured `APP_NAME`, and the settings search finds them by it (#1314, thanks @ghozty) + +### Fixes + +- **Push**: Every push ends with a notification, also one that brings nothing new (a redelivery, mail already read or already announced), and a slow preview gives way to the generic notification after 10 seconds. Safari treats a push without a notification as silent and drops the subscription after three (#1322, thanks @oguzyilmaznet) +- **Mail**: A draft written as a group identity is saved in the group account's Drafts, where its Sent copy also goes, instead of in your own (#1090, thanks @hildebrandttk) +- **Mail**: A sub-address of your own address (you+tag@) sends from your account, even when a shared catch-all account has a wildcard identity for the domain +- **Mail**: Bulk delete, trash and move send each message through the login that owns it. With accounts on two servers that use the same account id, they went through the first login and could hit an unselected message on the other server (#1307, #1326, thanks @VasilisNtovolos) +- **Mail**: Dragging several messages out as a ZIP fails with a message when one of them can't be fetched, instead of leaving it out, and the same selection can be tried again (#1306, thanks @VasilisNtovolos) +- **Tasks**: A task alert relative to the start fires from the task's start instead of its due date. A task without a start still counts from its due date (#1328, thanks @ClapsAndJiggles) +- **Plugins**: Plugins installed by the admin are listed in browsers that can't cache their code, such as some private windows (#1327, thanks @ovizii) +- **i18n**: The Ukrainian "Forward" and "Move to" are translated as the actions, not as "ahead" and "go to" (#1311, thanks @vladonv) + +## 1.17.1 (2026-10-22) + +### Security + +- **Dependencies**: Next.js 16.4.2 and DOMPurify 3.4.17 fix six published advisories, three of them critical. None of them reached Bulwark: the webmail has no `use cache` functions and no Server Actions, its image optimizer refuses SVG and runs on sharp 0.35.5, which already carries the fix, and the mail sanitizer uses none of the DOMPurify modes the bypasses need (SAFE_FOR_TEMPLATES, IN_PLACE, XHTML parsing). Updating is still recommended + +1.17.1 stops the background-notifications prompt from asking you to turn push on while the app is still restoring a subscription the browser dropped, and an open tab now repairs a dropped subscription on its own. The message list stays within the screen on phones, and a browser that was already signed in hands a mobile sign-in to the app again. + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- [@bultester](https://github.com/bultester) +- Anonymous +- Anonymous +- Anonymous + +**Monthly** + +- [@superspacehosting](https://github.com/superspacehosting) +- [@naokton](https://github.com/naokton) +- [@yerTools](https://github.com/yerTools) +- [@jsaathof](https://github.com/jsaathof) +- [@berkersal](https://github.com/berkersal) +- [@NABarnes](https://github.com/NABarnes) +- [@felixzieger](https://github.com/felixzieger) +- [@pr0ton11](https://github.com/pr0ton11) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) +- [@HiltMundell](https://github.com/HiltMundell) + +### Changes + +- **Dependencies**: vitest 4.1.12 and vite 8.3.5 (npm audit) + +### Fixes + +- **Push**: The background-notifications prompt waits for the background re-sync and only asks if push is still off afterwards, instead of appearing while the app is restoring a subscription Safari dropped (#1321, thanks @oguzyilmaznet) +- **Push**: A tab that stays open notices when the browser drops or replaces its push subscription and registers the new one at once, and coming back to the tab recreates a lost subscription instead of waiting for the next reload (#1321) +- **Mobile**: On phones, the message list is never wider than the screen, so the header's buttons, each row's date and the subject's ellipsis stay on screen and the selection toolbar compacts its buttons +- **Login**: A mobile sign-in opened in a browser that was already signed in keeps the sign-in form and hands the sign-in to the app, instead of opening the mailbox and leaving the app stuck in its sign-in sheet + +## 1.17.0 (2026-10-21) + +1.17.0 gives plugins real panels: a panel next to the message you write or read can fill its height, start folded, be opened by the plugin, and be dragged wider, and plugins get right-click menus on selected text in the composer and in a message. Plugins can also search mail and the settings, read the user's preferences and the app's version. The AI Writing Assistant in the marketplace builds on this and needs this release. The reader's side panel stays open from message to message, and the admin panel is reworked around a settings-style sidebar with one URL per page. The Docker images move to Node.js 24.22.1 for a Node.js security fix. + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- [@bultester](https://github.com/bultester) +- Anonymous +- Anonymous +- Anonymous + +**Monthly** + +- [@superspacehosting](https://github.com/superspacehosting) +- [@naokton](https://github.com/naokton) +- [@yerTools](https://github.com/yerTools) +- [@jsaathof](https://github.com/jsaathof) +- [@berkersal](https://github.com/berkersal) +- [@NABarnes](https://github.com/NABarnes) +- [@felixzieger](https://github.com/felixzieger) +- [@pr0ton11](https://github.com/pr0ton11) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) +- [@HiltMundell](https://github.com/HiltMundell) + +### Security + +- **Server**: The Docker images are built on Node.js 24.22.1, which fixes an out-of-bounds write in the Brotli decoder (CVE-2026-111497). The server decodes Brotli responses when it fetches a calendar subscription, a WebDAV share or a plugin request, so a signed-in user could point it at a host that sends a crafted response and run code on the server. The same Node.js release fixes TLS hostname checks for servers addressed by IP. Standalone installs run on the system's Node.js: update it to 22.23.4, 24.22.1 or 26.12.1 + +### Features + +- **Plugins**: A panel slot (`composer-sidebar`, `composer-sidebar-right`, `email-detail-sidebar`) can fill its panel and scroll inside (`fill`), start folded until the user or the plugin opens it (`startFolded`), and be opened, closed and toggled by the plugin with `api.ui.openPanel`, `closePanel` and `togglePanel`, which answer null where no panel is on screen (phones), so a plugin can fall back to a dialog. A panel opened this way gets a `focusRequest` prop +- **Plugins**: The composer's plugin panels get the composer id, the draft and the selected text as it changes; the reader's side panel gets the selected text; reader frames learn whether they are on a phone, a tablet or a desktop (`deviceType`) +- **Plugins**: Right-click menus on selected text: `composer-selection-menu` in the message being written and `reader-selection-menu` in an open message. The browser's own menu stays until a plugin adds entries; the webmail then draws Cut and Copy and the plugin's entries, and the click carries the selected text +- **Plugins**: Text selected inside a message's body (an HTML mail in its frame) now reaches `onTextSelectionChange` +- **Plugins**: Plugins can search mail with `api.email.search`, search the settings and read the user's preferences, and read the app's name and version with `api.app.getInfo` +- **Mail**: The reader's side panel stays open when you move to another message, and its toggle sits in the reader toolbar, which now runs over the side panels to the right edge +- **Composer**: The plugin panels beside the composer can be dragged wider or narrower, and keep their width +- **Admin**: The admin panel is reworked around a settings-style sidebar with one URL per page, the real navigation rail and extension icons + +### Fixes + +- **Mail**: Rescheduling a scheduled message cancels the original send before creating the new one, so it works on servers that allow one active submission per message (#1308) +- **Mail**: Dragging messages out of the list and downloading attachments, inline images and the raw message in the Pro view use the account that owns the message, so shared and secondary-account messages no longer fail (#1299, #1300, thanks @VasilisNtovolos) +- **Mail**: Deleting a message from its hover actions shows the actions on the next row that slides under the pointer, so you can delete one after another from the same spot (#1297, thanks @dealerweb) +- **Mail**: PDF attachment previews open in Safari (#1301) +- **Mail**: The contact card for a sender or recipient opens in place instead of sliding in from the top left +- **Plugins**: A plugin's passphrase dialog stays open while you type (#1302) +- **Plugins**: The push preference mirror no longer throws in sandboxed plugin frames (#1298, thanks @dealerweb) +- **Layout**: A pane divider stops following the mouse when the button is released over a frame +- **i18n**: The German dialog that hands a sign-in to the mobile app is translated (#1296, thanks @dealerweb) + +## 1.16.0 (2026-10-19) + +1.16.0 regroups the settings into fewer pages with an Advanced switch for the power-user options, shows the message list as columns on request, turns @mentions into contact chips, and opens the company directory in contacts and the composer. The marketplace can install any version of an extension and roll back to a replaced one, admins get policies for the colour mode, archiving and the provider sign-out, and the Estonian locale arrives. A security audit of the whole app fixed cross-account actions on repeated message ids and about thirty smaller findings. + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- [@bultester](https://github.com/bultester) +- Anonymous +- Anonymous +- Anonymous + +**Monthly** + +- [@superspacehosting](https://github.com/superspacehosting) +- [@naokton](https://github.com/naokton) +- [@yerTools](https://github.com/yerTools) +- [@jsaathof](https://github.com/jsaathof) +- [@berkersal](https://github.com/berkersal) +- [@NABarnes](https://github.com/NABarnes) +- [@felixzieger](https://github.com/felixzieger) +- [@pr0ton11](https://github.com/pr0ton11) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) +- [@HiltMundell](https://github.com/HiltMundell) + +### Security + +- **Mail**: Delete, move and drag in merged views and cross-account search act on the clicked message. When two accounts had messages with the same id, the action could hit the other account's message, which also made sent mail vanish from the Sent folder (#857, GHSA-8x23-qw3w-mhrx, thanks @FeliceBalsamo and @knittingcode) +- **Mail**: HTML attachments preview as sanitised text in a locked frame. The preview used to open the sender's HTML as a same-origin document that could run script in the webmail origin +- **Mail**: A crafted style attribute, read-receipt header, mail body or subject no longer freezes the reader or the message list +- **Mail**: Only an SPF or DKIM pass aligned with the From domain counts as sender verification, and oversized winmail.dat and embedded message parts stay attachments instead of being decoded on open +- **Mail**: Attachment names show without directional override characters, and imported and synced HTML templates are sanitised before the composer previews them +- **Login**: The login page asks before handing a sign-in to the mobile app, and only hands it to the app's own callback link +- **Login**: Failed sign-ins are capped per client at 5 in 15 minutes, and the client address is forwarded to the mail server as X-Forwarded-For so the server's own limits hit the client instead of the webmail. Add the webmail to Stalwart's server.proxy.trusted-networks for that to take effect +- **Login**: Synced settings are keyed on the account a token login resolves to, not on the claimed name, so two users can no longer share a settings blob +- **Admin**: OAuth client secrets and the session secret stay out of the admin audit log, the config API and dashboard writes +- **Admin**: OAuth discovery no longer follows a redirect to another host, the extension directory URL cannot point at a private address, a directory keyring older than the newest one seen is refused, marketplace versions are validated and a silent downgrade on update is refused, and the installer writes its env file readable by its owner only +- **Plugins**: Plugin POSTs stay off the plugin proxy, secrets, agenda, ical and translate routes, only a plugin known to the server can connect and use credentials, plugin-authored HTML drops remote images, and a plugin frame that navigated to another document gets no more host messages +- **Server**: TLS responses carry HSTS and the X-Powered-By header is gone. Upstream mail server requests get a deadline, CalDAV discovery probes and concurrent favicon fetches are bounded, and calendar feeds, synced settings blobs and the settings route have size caps + +### Features + +- **Settings**: The settings are regrouped into Account, Security, Notifications, General and Appearance, the mail pages Message list, Reading, Composing, Folders & tags, Filters and Out of office, then Calendar, Contacts, Files and Add-ons with the plugin pages under it, and Backup & reset. Every page has a jump row to its sections, settings links can name a section, and old settings links keep working +- **Settings**: An Advanced switch hides power-user settings until turned on. Search results and section links that point at a hidden setting turn it on +- **Settings**: Themes show as one row with a Show all button, and the settings search box looks like the mail search box +- **UI**: The message list can show as columns with one-line conversation rows (#262, thanks @fdisamuel) +- **Composer**: Typing @ offers contacts and adds the picked one as a recipient. Mentions become chips with a contact card on hover, in the composer and in received mail +- **Contacts**: The company directory can be browsed in contacts and the composer (#1253, thanks @diegonix) +- **Mail**: "Remove from this folder" takes a message out of just the open folder when it is in several (#615, thanks @arnauda-gh) +- **Mail**: n jumps to the next unread message (#514, thanks @muelli) +- **Mail**: Shift+arrow keys extend the selection (#992, thanks @IntExCZ), and while selecting, a click anywhere on a row ticks it (#1014, thanks @mmomjian) +- **Mail**: Search results from all folders show the folder (#1172, thanks @PoltoS) +- **Mail**: Your own folders are listed in a separate section of the sidebar (#1205, thanks @roedy-dev) +- **Mail**: The counter can be set per folder, e.g. keep the Drafts count with totals off (#982, thanks @mbjboon-netizen) +- **Mail**: The account dot in the unified view can be hidden (#912, thanks @mmomjian) +- **Mail**: Sender icons are refreshed when they changed on the sender's website (#960, thanks @IntExCZ) +- **Composer**: Message priority can be set in the composer (#970, thanks @douwezijlstra-frl) +- **Composer**: Templates get numeric date, day, month and year placeholders (#1284, thanks @Yahatix) +- **Accounts**: When the admin turns on "Account List Backup: Passwords", saving the account list offers to include the passwords of accounts signed in with a plain password, encrypted in the browser. Restoring signs those accounts in in the background (#1247, thanks @lucamzanon) +- **Admin**: The theme policy can set and lock the colour mode (#1280, thanks @chodid) +- **Admin**: A configured logout URL ends the provider session for providers whose discovery has no end_session_endpoint (#1279, thanks @chodid) +- **Admin**: A policy gate turns archiving off (#1257, #1258, thanks @HOLOEYE-JZ) +- **Admin**: The admin panel asks before leaving with unsaved changes (#1255, thanks @passibe15) +- **Admin**: The version is hidden from the connector probe when the login page hides it (#896, thanks @x86dev) +- **Marketplace**: Any version of an extension can be installed. The detail page lists every version, previews the picked one and warns about withdrawn versions, advisories, deprecations and suspensions, the listing can be sorted and filtered to installed extensions with an updates strip, and an install link can name the version to offer +- **Marketplace**: Replaced plugin and theme versions are kept and can be rolled back to +- **Plugins**: The UI kit gets a Combobox (#1251, thanks @tobiasge) +- **Translations**: Estonian is a new locale, including the company directory and the strings added since (#1254, thanks @raudraido) +- **Demo**: The demo follows one persona with a work account, a personal account and a shared Support mailbox, with real headers, an invitation, mentions, tags, templates and files + +### Changes + +- **Marketplace**: The marketplace talks to the extension directory's v2 API only. A self-hosted directory older than 2026-10-14 no longer works with it +- **Plugins**: A user credential now exists only for a registry plugin or an admin-approved upload whose manifest declares it. Approvals made before this version need a fresh approval request before the plugin can use credentials +- **UI**: The whole app uses the theme's sans font (#1278, thanks @chodid) +- **UI**: The app switcher uses simpler calendar, contacts and files icons, and the calendar, contacts, files and settings sidebar headers have the mail header's height +- **Performance**: Starting a search no longer re-renders every row + +### Fixes + +- **Mail**: Push notifications can be enabled for an account with access to a shared folder (#1050, thanks @magnetic6) +- **Mail**: The list shows the mail's own date and keeps it on .eml import (#891, thanks @johnnyq) +- **Mail**: PGP and S/MIME messages inside conversations are decrypted (#902, thanks @rajil) +- **Mail**: Attachments preview in the installed app instead of opening a blank window (#865, thanks @navidrast) +- **Mail**: Message previews drop plain-text emphasis markers (#1260, thanks @mmomjian) +- **Mail**: Today's and yesterday's mail are told apart by the time zone setting in the list and the sender line (#1259, #1262, thanks @mmomjian and @balabommablock-cpu) +- **Mail**: The category tab filter is kept when the mailbox refreshes, and the whole folder comes back after a restart when a category tab was open (#1264, #1265, thanks @alyyousuf7) +- **Mail**: Shared folders from other servers are kept when they reuse an account id, and a shared folder reachable through two logins is listed once in cross views (#1108, thanks @hildebrandttk) +- **Mail**: Renaming a tag follows it into the filter rules that name it, and keeps vacation forwarding and plugin vetoes while doing so (#854, thanks @dev-hive-kazniisa) +- **Mail**: Bulk archive across accounts is routed by the owning account, and Archive is only created where the account allows it (#1178, thanks @VasilisNtovolos) +- **Mail**: Conversation blobs are fetched from the message they belong to, and file previews stay open in development Strict Mode (#1287, #1288, thanks @VasilisNtovolos) +- **Mail**: A shared conversation is marked read on the owner account, and the mark-as-read delay is followed when expanding a conversation or opening a Pro folder row (#1272, thanks @Peterka35) +- **Mail**: The reader's Move, Tag and Rules flyouts open on the side with room (#1261, thanks @beja19) +- **Mail**: Vacation filter rules are skipped on shared accounts without Sieve (#1263, thanks @somus) +- **Mail**: Nextcloud filter markers inside rule text are ignored +- **Composer**: A reply to a message from another connected account is sent from that account to the right recipient (#703, #1270, thanks @jesperordrup and @vixh) +- **Composer**: Replies whose From is picked after the composer opens are signed (#842, thanks @mmomjian) +- **Composer**: A contact group whose member is not an address is no longer sent (#795, thanks @brambuijs) +- **Composer**: Your own address stays out of reply-all before your identities have loaded (#803, thanks @lucletoffe) +- **Composer**: Sent mail keeps blank lines instead of collapsing them (#1015, thanks @mbjboon-netizen) +- **Composer**: Inline images in replies and forwards are attached once (#1125, thanks @guisea) +- **Composer**: The quote containment styles stay in the editor and out of the sent mail, and a quoted original can no longer paint over the composer +- **Composer**: The composer opens when the mail client can't report directory support +- **Composer**: A click on a mention chip opens the contact card instead of the mailto handler in Firefox +- **Calendar**: Zero-length events end at their start instead of midnight (#837, thanks @Flying-Bas) +- **Calendar**: Invitation times show in your time zone (#1252, thanks @TomekCodeWave) +- **Calendar**: Answering an invite sent to a non-primary identity works (#1283, thanks @taranasus) +- **Calendar**: The visible range is refetched after creating a recurring event (#1268, thanks @sanitz) +- **Calendar**: Month view day columns align with the header (#1286, thanks @tobiasge) +- **Tasks**: An all-day task's due and start are sent as a LocalDateTime (#1256, thanks @sanitz) +- **Notifications**: Push notifications stay silent when the notification sound is off (#1289, thanks @chreds) +- **Admin**: The admin panel loads when the config is read-only, policy defaults are applied to user settings, and OAuth auto-setup is refused before touching Stalwart when the config is read-only (#1248, #1249, #1250, thanks @chodid) +- **Accounts**: Restored sign-ins are remembered only when the restoring account is remembered, and contact and calendar data that arrives after switching accounts is dropped instead of landing in the other account +- **Plugins**: api.email.get reads a listed message of a shared account and no longer reads mail of other connected logins (#1273, #1274, thanks @alyyousuf7) +- **Plugins**: A plugin slot's callbacks keep working when its props change while it loads (#1266, thanks @dedoitalia) +- **Plugins**: Plugin dropdown messages are checked before the host draws them +- **Lite**: Demo portraits load from the mount prefix so a bundle under a subpath stays self-contained, and the dev server stays inside its export directory +- **Translations**: German strings that still showed in English (#1285, thanks @dealerweb), contact field types and more French strings + ## 1.15.0 (2026-10-15) 1.15.0 opens the plugin system up: plugins can add UI in most places of the app, hear and stop most actions, and use accounts and API keys through the server without seeing them, and users can install their own plugins once an admin has reviewed the code. Opening folders and messages is faster, with far fewer re-renders and requests. Calendars get default reminders, notifications can be muted per folder or account, and every sign-in is now remembered unless the admin shows the Remember me box. diff --git a/Dockerfile b/Dockerfile index c9efc1cfa..f41759013 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN rm -rf "app/api/dev-jmap" && \ find . -path ./node_modules -prune -o \ \( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \ -prune -exec rm -rf {} \; && \ + node scripts/build-plugin-sandbox.mjs && \ npx next build --webpack FROM node:24-alpine AS runner diff --git a/README.md b/README.md index 930072229..541deac73 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,16 @@ Bulwark Webmail is a self-hosted webmail client for [Stalwart Mail Server](https

Calendar week view with events from several calendars - Contact details with recent mail and upcoming events + Contact details with recent mail and upcoming events
Calendar week view  ·  Contact details with recent mail and upcoming events

- Rich-text composer next to the message list + The composer with an @mention chip and the contact card it opens The inbox split diagonally between the light and dark theme
- Composer with identities and formatting  ·  Light and dark themes + Composer with @mention cards  ·  Light and dark themes

## Features diff --git a/VERSION b/VERSION index 141f2e805..06fb41b63 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.15.0 +1.17.2 diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index 380ac321d..28a75200c 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -10,6 +10,8 @@ import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-o import { Loader2, AlertCircle } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; +import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff"; +import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; function OAuthCallbackInner() { const router = useRouter(); @@ -18,6 +20,9 @@ function OAuthCallbackInner() { const t = useTranslations("login"); const { loginWithOAuth, loginWithServerSso } = useAuthStore(); const [error, setError] = useState(null); + // A sign-in started for the mobile app, waiting for the user to confirm + // the handoff (lib/auth/mobile-handoff.ts). + const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null); // The authorization code is single-use. React's development double effect // ran this twice: the second pass no longer saw the pairing re-auth flag // (the first had removed it) and spent the code on the login flow instead. @@ -135,14 +140,14 @@ function OAuthCallbackInner() { // Server-side SSO flow - state was stored in encrypted httpOnly cookie. // Branch on mobile handoff first: the login page left a marker in // sessionStorage if it kicked this OAuth dance off for the mobile app. - let mobileRedirectUri: string | null = null; + let mobileRedirectUri = ""; let mobileState: string | null = null; try { - mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri"); + mobileRedirectUri = mobileHandoffTarget(sessionStorage.getItem("mobile_redirect_uri")); mobileState = sessionStorage.getItem("mobile_state"); } catch { /* sessionStorage may be unavailable */ } - if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) { + if (mobileRedirectUri) { // Drive /api/auth/sso/complete directly so we can read the tokens // out of the response - loginWithServerSso would consume them and // wire up the webmail auth store, which isn't useful here. The @@ -187,7 +192,12 @@ function OAuthCallbackInner() { sessionStorage.removeItem("mobile_redirect_uri"); sessionStorage.removeItem("mobile_state"); } catch { /* ignore */ } - window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); + // Shown to the user first; the navigation happens on their confirmation. + setPendingHandoff({ + url: buildMobileHandoffUrl(mobileRedirectUri, Object.fromEntries(fragment)), + // The complete route names the account's server, not the user. + username: typeof data.username === "string" ? data.username : serverUrl, + }); } catch { setError("token_exchange_failed"); } @@ -220,6 +230,21 @@ function OAuthCallbackInner() { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + if (pendingHandoff) { + return ( +
+ { + setPendingHandoff(null); + router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)); + }} + /> +
+ ); + } + if (error) { return (
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index a4ee53ec8..1aa5b7c3f 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; +import { usePolicyStore } from "@/stores/policy-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; @@ -31,6 +32,8 @@ import { } from "@/lib/auth/lite-oauth"; import { toAsciiDomain } from "@/lib/idn"; import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; +import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; +import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; /** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */ function completeAddressDomain(username: string): string { @@ -133,11 +136,6 @@ function VersionBadge() { ); } -// Only redirect targets matching this scheme are honored by the mobile -// handoff path. Without the check the login page becomes an open redirector -// that funnels password and token material to any caller-supplied URL. -const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://"; - function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); @@ -149,18 +147,19 @@ function LoginPageContent() { // these params. We grab them once at mount and stash them in a ref so any // login path that completes (password or OAuth) can hand control back to // the app instead of routing into /mail. - const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? ""; + // Only the app's own callback link is a handoff target, and nothing is + // handed over before the user confirms it (lib/auth/mobile-handoff.ts). const rawMobileState = searchParams.get("mobile_state") ?? ""; - const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME) - ? rawMobileRedirectUri - : ""; + const mobileRedirectUri = mobileHandoffTarget(searchParams.get("mobile_redirect_uri")); const mobileState = mobileRedirectUri ? rawMobileState : ""; const isMobileHandoff = Boolean(mobileRedirectUri); + const [pendingHandoff, setPendingHandoff] = useState<{ url: string; username: string } | null>(null); usePrefetchMailView(!isMobileHandoff); const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked); // Login logo sizing: when a max height/width is configured, drop the fixed // 64×64 box so the logo (e.g. a wide wordmark) can render at its true size. @@ -365,21 +364,39 @@ function LoginPageContent() { } }, [serverUrl]); + // Mobile handoff: hand the verified credentials back to the mobile app + // instead of opening the mailbox. handleSubmit captured the values needed + // for the fragment. Runs once per sign-in: whichever of the effect below + // and handleSubmit gets here first takes the payload. + const offerMobileHandoff = useCallback(() => { + const payload = mobileHandoffPayloadRef.current; + if (!payload) return; + mobileHandoffPayloadRef.current = null; + // Shown to the user first; the navigation happens on their confirmation. + setPendingHandoff({ + url: buildMobileHandoffUrl(mobileRedirectUri, { flow: "password", ...payload, state: mobileState }), + username: payload.username, + }); + }, [mobileRedirectUri, mobileState]); + useEffect(() => { - if (isAuthenticated && !isAddAccountMode) { - // Mobile handoff: the password path completes here once the auth store - // flips isAuthenticated. Hand the verified credentials back to the - // mobile app instead of pushing to /mail. handleSubmit captured the - // values needed for the fragment. - if (isMobileHandoff && mobileHandoffPayloadRef.current) { - const fragment = new URLSearchParams({ - flow: "password", - ...mobileHandoffPayloadRef.current, - state: mobileState, - }); - window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); - return; - } + if (!isAuthenticated) return; + const action = signedInLoginAction({ + isMobileHandoff, + isAddAccountMode, + hasHandoffPayload: mobileHandoffPayloadRef.current !== null, + }); + if (action === "handoff") { + // The password path completes here once the auth store flips + // isAuthenticated. + offerMobileHandoff(); + return; + } + // A browser that was already signed in keeps the form: the app needs a + // sign-in of its own, and the mailbox would strand the user in the app's + // sign-in sheet. handleSubmit offers the hand-off after they sign in + // (isAuthenticated stays true, so this effect doesn't run again). + if (action === "mailbox") { let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); @@ -390,7 +407,7 @@ function LoginPageContent() { } catch { /* ignore */ } router.push(toRouterPath(redirectTo)); } - }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]); + }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, offerMobileHandoff]); useEffect(() => { clearError(); @@ -861,8 +878,11 @@ function LoginPageContent() { // being remembered: on a shared computer the next person sees the list. if (!rememberMeEnabled || persistSession) saveUsername(formData.username); if (isMobileHandoff) { - // The isAuthenticated effect handles the redirect; nothing else to - // do here. Don't push to / - that would race the deep link. + // Usually the isAuthenticated effect has offered the hand-off by + // now. A browser that was already signed in doesn't flip + // isAuthenticated, so offer it here (a no-op once taken). Don't push + // to / - that would race the deep link. + offerMobileHandoff(); return; } router.push('/'); @@ -956,8 +976,8 @@ function LoginPageContent() { if (demoMode && !isAddAccountMode) { return (
- {/* Theme toggle */} -
+ {/* Theme toggle, hidden while the theme policy locks the mode (#1280) */} +
@@ -164,40 +166,42 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
- + update(i, { id: e.target.value })} placeholder="main" - className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`} + className={cn(fieldClass, isDuplicate && 'border-destructive')} /> {isDuplicate &&

Duplicate id

}
- + update(i, { label: e.target.value })} placeholder="Main server" - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={fieldClass} />
- +
update(i, { url: e.target.value })} placeholder="https://mail.example.com" - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={fieldClass} /> @@ -205,7 +209,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
-
)}
diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx new file mode 100644 index 000000000..219a801c3 --- /dev/null +++ b/app/(main)/admin/_pages/auth.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, Sparkles } from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes'; +import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, inputClass } from '@/components/admin/config-items'; +import { cn } from '@/lib/utils'; + +interface ConfigEntry { + // Sensitive keys (sessionSecret, oauthClientSecret) come back with + // `value` omitted and `hasValue` set instead - the server never echoes + // the raw secret to the client. + value?: unknown; + source: 'admin' | 'env' | 'default'; + hasValue?: boolean; +} + +const PAGE = adminPageDef('auth'); + +export function AuthPage() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useFlash(); + const [configReadOnly, setConfigReadOnly] = useState(false); + + useEffect(() => { + fetchConfig(); + apiFetch('/api/setup/status') + .then(res => (res.ok ? res.json() : null)) + .then(data => setConfigReadOnly(data?.readOnly === true)) + .catch(() => {}); + }, []); + + async function fetchConfig() { + setLoading(true); + const res = await apiFetch('/api/admin/config'); + if (res.ok) setConfig(await res.json()); + setLoading(false); + } + + function handleChange(key: string, value: unknown) { + setEdits(prev => ({ ...prev, [key]: value })); + setMessage(null); + } + + function currentValue(key: string): unknown { + if (key in edits) return edits[key]; + return config[key]?.value; + } + + async function handleSave() { + if (Object.keys(edits).length === 0) return; + setSaving(true); + setMessage(null); + + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edits), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Authentication settings saved.' }); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + async function handleRevert(key: string) { + const res = await apiFetch('/api/admin/config', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) { + setEdits(prev => { const next = { ...prev }; delete next[key]; return next; }); + await fetchConfig(); + } + } + + const [setupRunning, setSetupRunning] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); + const [setupOrigin, setSetupOrigin] = useState(''); + const [setupIssuer, setSetupIssuer] = useState(''); + const [setupOauthOnly, setSetupOauthOnly] = useState(false); + + function openSetupDialog() { + if (typeof window === 'undefined') return; + const origin = window.location.origin; + const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || ''; + setSetupOrigin(origin); + setSetupIssuer(jmapUrl || origin); + setSetupOauthOnly(currentValue('oauthOnly') === true); + setSetupOpen(true); + } + + async function handleAutoSetup() { + setSetupRunning(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/oauth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + origin: setupOrigin.trim().replace(/\/+$/, ''), + issuerUrl: setupIssuer.trim().replace(/\/+$/, ''), + oauthOnly: setupOauthOnly, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ + type: 'success', + text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`, + }); + setEdits({}); + setSetupOpen(false); + await fetchConfig(); + } else { + const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; + setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail }); + } + } catch (err) { + setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' }); + } finally { + setSetupRunning(false); + } + } + + const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, '')); + const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, '')); + + const hasEdits = Object.keys(edits).length > 0; + + useAdminUnsavedChanges('auth', hasEdits); + + if (loading) { + return ( +
+ +
+ Loading +
+
+ ); + } + + return ( +
+ + + setMessage(null)} /> + + {setupOpen && ( +
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} + > +
+
+

Auto-configure OAuth

+

+ Verify the URLs below before continuing. The webmail and Stalwart can live on different domains. +

+
+
+
+ + setSetupOrigin(e.target.value)} + disabled={setupRunning} + placeholder="https://webmail.example.com" + className={cn(inputClass, 'w-full')} + /> +

+ Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart. +

+ {!setupOriginValid && setupOrigin.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+
+ + setSetupIssuer(e.target.value)} + disabled={setupRunning} + placeholder="https://mail.example.com" + className={cn(inputClass, 'w-full')} + /> +

+ Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL. +

+ {!setupIssuerValid && setupIssuer.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+ +
+
+ + +
+
+
+ )} + +
+ + + + +
+ + {configReadOnly && ( +

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

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

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

+ +
+ ) : ( +

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

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

{msg}

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

No activity recorded yet

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

{entry.description}

} + + setMessage(null)} /> + +
+ + + void setForced(v)} /> + + + {entry.source?.kind === 'marketplace' ? ( + + Open in marketplace + + ) : ( + Upload + )} + + + + {kind === 'plugin' && ( + + {perms.length > 0 && ( +
    + {perms.map((perm) => { + const risky = HIGH_RISK_PERMISSIONS.has(perm); + return ( +
  • + {risky + ? + : } + {perm} + {risky && High risk} +
  • + ); + })} +
+ )} +
+ )} + + + + v{entry.version} + + {previous.map((version) => ( + + + + ))} + + + {kind === 'plugin' && } + + {/* The plugin's own settings come last: they are the long part. */} + {kind === 'plugin' && hasSchema && schema && ( + + {Object.entries(schema).map(([key, field]) => ( + + setRevealSecrets((prev) => ({ ...prev, [key]: !prev[key] }))} + onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))} + /> + + ))} + + )} +
+ + setFormValues(savedValues)} + saveLabel="Save settings" + /> +
+ ); +} + +function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange }: { + field: ConfigField; + value: string; + stored: boolean; + revealed: boolean; + onReveal: () => void; + onChange: (value: string) => void; +}) { + const width = 'w-full sm:w-64'; + if (field.type === 'boolean') { + return ( + onChange(v ? 'true' : 'false')} /> + ); + } + if (field.type === 'select' && field.options) { + return ( + + ); + } + if (field.type === 'secret') { + return ( +
+ onChange(e.target.value)} + placeholder={stored ? 'Saved. Type to replace' : field.placeholder || ''} + aria-label={field.label} + autoComplete="off" + className={cn(inputClass, 'w-full pe-9 font-mono')} + /> + +
+ ); + } + return ( + onChange(e.target.value)} + placeholder={field.placeholder || ''} + aria-label={field.label} + autoComplete="off" + className={cn(inputClass, width)} + /> + ); +} + +/** + * The plugin's own `admin-plugin-page` slot, if it offers one. Sandboxed + * plugins ship a React component under `slots['admin-plugin-page']` and the + * host gives it an iframe here. + */ +function PluginAdminSection({ pluginId }: { pluginId: string }) { + const offers = usePluginSlotOffers('admin-plugin-page'); + const offer = offers.find((o) => o.pluginId === pluginId); + if (!offer) return null; + return ( + +
+ +
+
+ ); +} diff --git a/app/(main)/admin/_pages/logs.tsx b/app/(main)/admin/_pages/logs.tsx new file mode 100644 index 000000000..85f56a1b9 --- /dev/null +++ b/app/(main)/admin/_pages/logs.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { RefreshCw, Loader2, ScrollText } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { Badge } from '@/components/admin/badge'; +import { EmptyState } from '@/components/admin/empty-state'; +import { inputClass } from '@/components/admin/config-items'; +import type { AuditEntry } from '@/lib/admin/types'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { apiFetch } from '@/lib/browser-navigation'; +import { cn } from '@/lib/utils'; + +const PAGE = adminPageDef('logs'); + +export function LogsPage() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [actionFilter, setActionFilter] = useState(''); + const limit = 50; + + const fetchLogs = useCallback(async () => { + setLoading(true); + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (actionFilter) params.set('action', actionFilter); + + const res = await apiFetch(`/api/admin/audit?${params}`); + if (res.ok) { + const data = await res.json(); + setEntries(data.entries || []); + setTotal(data.total || 0); + } + setLoading(false); + }, [page, actionFilter]); + + useEffect(() => { fetchLogs(); }, [fetchLogs]); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + const initialLoading = loading && entries.length === 0; + + return ( +
+ {PAGE.description} · {total} total entries} + actions={( + <> + + + + )} + /> + + {initialLoading ? ( +
+ Loading +
+ ) : entries.length === 0 ? ( + + ) : ( + <> +
+ {entries.map((entry, i) => ( +
+
+ {entry.action} + + {new Date(entry.ts).toLocaleString()} + +
+
+ {formatDetail(entry.detail)} +
+
+ {entry.ip} +
+
+ ))} +
+ +
+ + + + + + + + + + + {entries.map((entry, i) => ( + + + + + + + ))} + +
TimeActionDetailsIP
+ {new Date(entry.ts).toLocaleString()} + + {entry.action} + + {formatDetail(entry.detail)} + + {entry.ip} +
+
+ + )} + + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

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

{ext.description}

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

{ext.longDescription}

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

{v.changelog}

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

No longer in the directory

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

    {text.body}

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

Iframe origins

+

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

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

{title}

+ {children &&

{children}

} +
+
+ ); +} + +function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record | null } }) { + const colors = preview.colors || {}; + const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff'); + const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a'); + const accent = colors.primary || colors.accent || '#7c5cff'; + const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5'); + const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5'); + + return ( +
+
+ {preview.variant} +
+
+
+ + Sample text +
+
+ Card surface +
+
+ {Object.entries(colors).slice(0, 6).map(([key, value]) => ( + + ))} +
+
+
+ ); +} diff --git a/app/(main)/admin/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx new file mode 100644 index 000000000..a58925975 --- /dev/null +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -0,0 +1,751 @@ +'use client'; + +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { + Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, + ArrowUpCircle, ExternalLink, RefreshCw, Ban, ShieldAlert, Info, Upload, +} from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { compareVersions } from '@/lib/version-compare'; +import type { MarketplaceListing } from '@/lib/admin/marketplace-directory'; +import { adminPageDef, adminPath } from '@/lib/admin/admin-nav'; +import { cn } from '@/lib/utils'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminLink } from '@/components/admin/admin-link'; +import { AdminButtonLink } from '@/components/admin/button-link'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { Badge } from '@/components/admin/badge'; +import { EmptyState } from '@/components/admin/empty-state'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { ConfigNote, inputClass } from '@/components/admin/config-items'; + +const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; + +interface SearchResult { + data: MarketplaceListing[]; + meta: { page: number; perPage: number; total: number }; + channel: 'stable' | 'beta'; + directoryUrl: string; +} + +interface InstalledEntry { + id: string; + kind: 'plugin' | 'theme'; + name: string; + version: string; + enabled: boolean; + source: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' } | null; +} + +interface UpdatesResult { + installed: InstalledEntry[]; + updates: Array<{ slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; changelog: string | null }>; + yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>; + deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>; + suspended: string[]; + advisories: Array<{ id: string; extension: string; severity: string; summary: string; fixedIn: string | null; installedVersion?: string }>; + unknown: string[]; + directoryUrl: string; + channel: 'stable' | 'beta'; + error: string | null; +} + +type TypeFilter = 'all' | 'plugin' | 'theme'; +type Sort = 'created' | 'downloads' | 'updated' | 'name'; +type View = 'browse' | 'installed'; + +const SORTS: Array<{ value: Sort; label: string }> = [ + { value: 'created', label: 'Newest' }, + { value: 'downloads', label: 'Most downloaded' }, + { value: 'updated', label: 'Recently updated' }, + { value: 'name', label: 'Name' }, +]; + +const PAGE = adminPageDef('marketplace'); + +export const detailHref = (slug: string, version?: string | null) => + `${adminPath('marketplace', slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`; + +/** The segmented Browse / Installed and All / Plugins / Themes switches. */ +const pillGroupClass = 'flex items-center gap-0.5 rounded-md bg-muted p-0.5 self-start'; +const pillClass = (active: boolean) => cn( + 'h-8 rounded px-3 text-sm font-medium transition-colors', + active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', +); + +/** + * Installs one version of an extension. A version the publisher withdrew, + * or one older than the installed version, comes back as 409; the admin is + * asked, and the install is repeated with `force` if they insist. Returns + * the error text to show, or null. + */ +export async function installVersion( + body: { slug: string; version: string; type: 'plugin' | 'theme' }, + confirm: (message: string) => boolean = (m) => window.confirm(m), +): Promise<{ ok: true; warnings: string[]; signature: string | null; updated: boolean } | { ok: false; error: string }> { + const send = (force: boolean) => apiFetch('/api/admin/marketplace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(force ? { ...body, force: true } : body), + }); + try { + let res = await send(false); + if (res.status === 409) { + const data = await res.json().catch(() => ({})); + if (data.yanked) { + if (!confirm(`${data.error}\n\nInstall it anyway?`)) return { ok: false, error: 'Not installed.' }; + res = await send(true); + } else if (data.downgrade) { + if (!confirm(`${data.error}\n\nInstall the older version anyway?`)) return { ok: false, error: 'Not installed.' }; + res = await send(true); + } else { + return { ok: false, error: data.error || 'Installation failed' }; + } + } + const data = await res.json().catch(() => ({})); + if (!res.ok) return { ok: false, error: data.error || 'Installation failed' }; + return { ok: true, warnings: data.warnings ?? [], signature: data.signature ?? null, updated: !!data.updated }; + } catch { + return { ok: false, error: 'Installation failed - network error' }; + } +} + +export function MarketplacePage() { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [sort, setSort] = useState('created'); + const [view, setView] = useState('browse'); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [perPage] = useState(12); + const [directoryUrl, setDirectoryUrl] = useState(null); + const [channel, setChannel] = useState<'stable' | 'beta'>('stable'); + const [installing, setInstalling] = useState>(new Set()); + const [message, setMessage] = useFlash(); + const [error, setError] = useState(null); + const [updates, setUpdates] = useState(null); + const [updatesLoading, setUpdatesLoading] = useState(true); + + const fetchExtensions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (typeFilter !== 'all') params.set('type', typeFilter); + params.set('page', String(page)); + params.set('perPage', String(perPage)); + params.set('sort', sort); + params.set('order', sort === 'name' ? 'asc' : 'desc'); + + const res = await apiFetch(`/api/admin/marketplace?${params}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || 'Failed to connect to extension directory'); + setExtensions([]); + return; + } + + const data: SearchResult = await res.json(); + setExtensions(data.data || []); + setTotal(data.meta?.total || 0); + setDirectoryUrl(data.directoryUrl ?? null); + setChannel(data.channel ?? 'stable'); + } catch { + setError('Failed to connect to extension directory. Make sure it is running.'); + setExtensions([]); + } finally { + setLoading(false); + } + }, [query, typeFilter, page, perPage, sort]); + + const fetchUpdates = useCallback(async () => { + setUpdatesLoading(true); + try { + const res = await apiFetch('/api/admin/marketplace/updates'); + if (res.ok) setUpdates(await res.json()); + } catch { /* the strip just stays empty */ } finally { + setUpdatesLoading(false); + } + }, []); + + useEffect(() => { fetchExtensions(); }, [fetchExtensions]); + useEffect(() => { fetchUpdates(); }, [fetchUpdates]); + + const [searchInput, setSearchInput] = useState(''); + useEffect(() => { + const t = setTimeout(() => { + setQuery(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + const markInstalling = (slug: string, on: boolean) => setInstalling((prev) => { + const next = new Set(prev); + if (on) next.add(slug); else next.delete(slug); + return next; + }); + + async function install(ext: { slug: string; name: string; type: 'plugin' | 'theme' }, version: string, isUpdate: boolean) { + markInstalling(ext.slug, true); + setMessage(null); + const result = await installVersion({ slug: ext.slug, version, type: ext.type }); + markInstalling(ext.slug, false); + if (!result.ok) { + setMessage({ type: 'error', text: result.error }); + return false; + } + const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : ''; + const signed = result.signature === 'verified' ? ', signed by the directory' : ''; + setMessage({ + type: 'success', + text: isUpdate + ? `"${ext.name}" updated to v${version}${signed}${warnings}` + : `"${ext.name}" installed (v${version})${signed}${warnings}`, + }); + setExtensions((prev) => prev.map((e) => (e.slug === ext.slug + ? { ...e, installed: true, installedVersion: version, installedSource: { kind: 'marketplace', slug: ext.slug, directoryUrl: directoryUrl ?? '' } } + : e))); + fetchUpdates(); + return true; + } + + async function updateAll() { + if (!updates) return; + const byId = new Map(updates.installed.map((e) => [e.id, e])); + let failed = 0; + for (const u of updates.updates) { + const entry = byId.get(u.slug); + if (!entry) continue; + const ok = await install({ slug: u.slug, name: entry.name, type: entry.kind }, u.version, true); + if (!ok) failed++; + } + setMessage(failed + ? { type: 'error', text: `${failed} update${failed === 1 ? '' : 's'} failed; see above` } + : { type: 'success', text: `All ${updates.updates.length} update${updates.updates.length === 1 ? '' : 's'} installed` }); + } + + const totalPages = Math.ceil(total / perPage); + const installedCount = updates?.installed.length ?? 0; + const noticeCount = updates + ? updates.updates.length + updates.yanked.length + updates.advisories.length + updates.deprecated.length + updates.suspended.length + : 0; + + return ( +
+ + {channel === 'beta' && ( + Beta channel + )} + {directoryUrl && ( + + + Open directory + + )} + + )} + /> + + setMessage(null)} /> + +
+ {updates && noticeCount > 0 && ( + { + const entry = updates.installed.find((e) => e.id === u.slug); + if (entry) install({ slug: u.slug, name: entry.name, type: entry.kind }, u.version, true); + }} + onUpdateAll={updateAll} + /> + )} + +
+
+
+ {([['browse', 'Browse'], ['installed', `Installed${installedCount ? ` (${installedCount})` : ''}`]] as const).map(([v, label]) => ( + + ))} +
+
+ + setSearchInput(e.target.value)} + aria-label={view === 'browse' ? 'Search extensions' : 'Filter installed'} + className={cn(inputClass, 'w-full ps-9')} + /> +
+ {view === 'browse' && ( + <> +
+ {(['all', 'plugin', 'theme'] as const).map((t) => ( + + ))} +
+ + + )} +
+ + {view === 'installed' ? ( + install({ slug: entry.id, name: entry.name, type: entry.kind }, version, true)} + /> + ) : ( + <> + {error && ( + Retry} + /> + )} + + {loading && !error && ( +
+ Loading +
+ )} + + {!loading && !error && extensions.length === 0 && ( + + )} + + {!loading && !error && extensions.length > 0 && ( + <> +
+ {total} extension{total !== 1 ? 's' : ''} found +
+
+ {extensions.map((ext) => ( + ext.latestVersion && install(ext, ext.latestVersion, ext.installed)} + /> + ))} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + )} + + )} +
+
+
+ ); +} + +/** Updates and warnings for what is installed, from one directory request. */ +function UpdatesSection({ + updates, + installing, + onUpdate, + onUpdateAll, +}: { + updates: UpdatesResult; + installing: Set; + onUpdate: (u: UpdatesResult['updates'][number]) => void; + onUpdateAll: () => void; +}) { + const names = new Map(updates.installed.map((e) => [e.id, e.name])); + const name = (slug: string) => names.get(slug) ?? slug; + const busy = installing.size > 0; + const hasNotices = updates.advisories.length > 0 || updates.yanked.length > 0 || updates.suspended.length > 0 || updates.deprecated.length > 0; + const title = updates.updates.length > 0 + ? `${updates.updates.length} update${updates.updates.length === 1 ? '' : 's'} available` + : 'Notices'; + + return ( + + {updates.updates.length > 0 && ( +
+ {updates.updates.length > 1 && ( +
+ +
+ )} +
    + {updates.updates.map((u) => ( +
  • +
    +
    + {name(u.slug)} + v{u.currentVersion} → v{u.version} + {u.channel === 'beta' && beta} +
    + {u.changelog &&

    {u.changelog}

    } +
    + +
  • + ))} +
+
+ )} + + {hasNotices && ( +
    + {updates.advisories.map((a) => ( +
  • + + + {name(a.extension)} + {' '}has a security advisory ({a.severity}): {a.summary} + {a.fixedIn ? ` Fixed in v${a.fixedIn}.` : ' No fixed version yet.'} + +
  • + ))} + {updates.suspended.map((slug) => ( +
  • + + + {name(slug)} + {' '}was suspended by the directory. Consider removing it. + +
  • + ))} + {updates.yanked.map((y) => ( +
  • + + + The installed v{y.version} of{' '} + {name(y.slug)} + {' '}was withdrawn by its publisher{y.reason ? `: ${y.reason}` : '.'} + +
  • + ))} + {updates.deprecated.map((d) => ( +
  • + + + {name(d.slug)} + {' '}is deprecated{d.message ? `: ${d.message}` : '.'} + {d.replacement && <> Use {d.replacement} instead.} + +
  • + ))} +
+ )} +
+ ); +} + +/** Everything installed here, with where it came from and whether it can be updated. */ +function InstalledView({ + updates, + loading, + filter, + installing, + onUpdate, +}: { + updates: UpdatesResult | null; + loading: boolean; + filter: string; + installing: Set; + onUpdate: (entry: InstalledEntry, version: string) => void; +}) { + const rows = useMemo(() => { + if (!updates) return []; + const q = filter.trim().toLowerCase(); + const newer = new Map(updates.updates.map((u) => [u.slug, u.version])); + const yanked = new Set(updates.yanked.map((y) => y.slug)); + const advisories = new Set(updates.advisories.map((a) => a.extension)); + return updates.installed + .filter((e) => !q || e.name.toLowerCase().includes(q) || e.id.toLowerCase().includes(q)) + .map((e) => ({ entry: e, newer: newer.get(e.id) ?? null, yanked: yanked.has(e.id), advisory: advisories.has(e.id) })); + }, [updates, filter]); + + if (loading && !updates) { + return ( +
+ Loading +
+ ); + } + + if (rows.length === 0) { + return ( + + ); + } + + return ( +
+ {updates?.error && ( + Could not check for updates: {updates.error} + )} + + {rows.map(({ entry, newer, yanked, advisory }) => { + const fromMarketplace = entry.source?.kind === 'marketplace' || entry.source === null; + const manageHref = entry.kind === 'plugin' ? adminPath('plugins', entry.id) : adminPath('themes', entry.id); + return ( + : } + title={entry.name} + titleExtra={( + <> + v{entry.version} + {!entry.enabled && Off} + {entry.source?.kind === 'upload' + ? }>Uploaded + : }>Marketplace} + {advisory && }>Advisory} + {yanked && }>Withdrawn} + + )} + control={( + <> + {newer && ( + + )} + Manage + + )} + /> + ); + })} + +
+ ); +} + +function Rating({ rating }: { rating: { average: number; count: number } }) { + if (!rating.count) return null; + return ( + + + {rating.average.toFixed(1)} + ({rating.count}) + + ); +} + +function ExtensionCard({ + extension, + installing, + onInstall, +}: { + extension: MarketplaceListing; + installing: boolean; + onInstall: () => void; +}) { + const isPlugin = extension.type === 'plugin'; + const previewHref = detailHref(extension.slug); + const offered = extension.latest; + const updateAvailable = extension.installed + && !!extension.installedVersion + && !!offered + && compareVersions(offered.version, extension.installedVersion) > 0; + + return ( +
+ {extension.bannerUrl && ( + + + + )} + +
+
+ {extension.iconUrl ? ( + + ) : isPlugin ? ( + + ) : ( + + )} +
+
+
+ + {extension.name} + + {extension.featured && ( + + )} +
+
+ + {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} + + {offered?.channel === 'beta' && beta} + {extension.deprecated && deprecated} + {extension.author && ( + + by {extension.author.displayName} + + )} +
+
+
+ +

+ {extension.description} +

+ + {extension.tags && extension.tags.length > 0 && ( +
+ {extension.tags.slice(0, 3).map(tag => ( + {tag} + ))} +
+ )} + +
+
+ + + {extension.totalDownloads.toLocaleString()} + + + {extension.permissions && extension.permissions.length > 0 && ( + + {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} + + )} +
+ + + Details + +
+
+ +
+ {extension.installed && updateAvailable && offered ? ( + + ) : extension.installed ? ( + } + title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined} + > + Installed{extension.installedVersion ? ` v${extension.installedVersion}` : ''} + + ) : !offered ? ( + } + title={`No published version runs on Bulwark v${CURRENT_APP_VERSION}. Open the details to see what the newest version needs.`} + > + Not for v{CURRENT_APP_VERSION} + + ) : ( + + )} +
+
+ ); +} diff --git a/app/(main)/admin/_pages/plugin-approvals-panel.tsx b/app/(main)/admin/_pages/plugin-approvals-panel.tsx new file mode 100644 index 000000000..0baedaf67 --- /dev/null +++ b/app/(main)/admin/_pages/plugin-approvals-panel.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { AlertTriangle, Check, Code, Loader2, X } from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { useAdminShell } from '@/components/admin/shell-context'; +import { Badge, type BadgeTone } from '@/components/admin/badge'; + +interface ApprovalEntry { + pluginId: string; + bundleHash: string; + status: 'pending' | 'approved' | 'denied'; + manifest: { + name?: string; + version?: string; + author?: string; + description?: string; + permissions?: string[]; + httpOrigins?: string[]; + apiPostPaths?: string[]; + }; + requestedBy: string; + requestedAt: string; + decidedAt?: string; +} + +interface SourceView { + entry: ApprovalEntry; + code?: string; + warnings?: string[]; + error?: string; +} + +const STATUS_TONE: Record = { + pending: 'warning', + approved: 'success', + denied: 'muted', +}; + +/** + * Plugins users installed themselves and asked to have approved. The admin + * decides on the package the user uploaded: its manifest and code are read + * on the server, and the approval is pinned to that exact package. Shown + * only while there is something to decide or to look back on. + */ +export function PluginApprovalsPanel() { + const [entries, setEntries] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [source, setSource] = useState(null); + const [showPast, setShowPast] = useState(false); + const refreshBadges = useAdminShell()?.refreshBadges; + + const load = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/plugin-approvals'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json() as { entries: ApprovalEntry[] }; + setEntries([...data.entries].sort((a, b) => + Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt))); + setError(null); + } catch (e) { + setError(`Could not load approval requests (${(e as Error).message})`); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') { + const key = `${entry.pluginId}:${entry.bundleHash}`; + setBusy(key); + try { + const res = decision === 'revoke' + ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' }) + : await apiFetch('/api/admin/plugin-approvals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await load(); + refreshBadges?.(); + } catch (e) { + setError(`Could not save the decision (${(e as Error).message})`); + } finally { + setBusy(null); + } + } + + async function viewSource(entry: ApprovalEntry) { + setSource({ entry }); + try { + const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + setSource({ entry, code: data.code, warnings: data.warnings }); + } catch (e) { + setSource({ entry, error: (e as Error).message }); + } + } + + if (!error && (entries === null || entries.length === 0)) return null; + + const pending = entries?.filter((e) => e.status === 'pending') ?? []; + const past = entries?.filter((e) => e.status !== 'pending') ?? []; + + const renderEntry = (entry: ApprovalEntry) => { + const key = `${entry.pluginId}:${entry.bundleHash}`; + const perms = entry.manifest.permissions ?? []; + const sendsTo = [...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])]; + return ( +
+
+ {entry.manifest.name ?? entry.pluginId} + v{entry.manifest.version} + {entry.status} +
+ {entry.manifest.description &&

{entry.manifest.description}

} +
+ {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()} +
+ {perms.length > 0 && ( +
+ {perms.map((p) => ( + {p} + ))} +
+ )} + {sendsTo.length > 0 && ( +
Can send data to: {sendsTo.join(', ')}
+ )} +
+ + {entry.status !== 'approved' && ( + + )} + {entry.status !== 'denied' && ( + + )} + {entry.status !== 'pending' && ( + + )} +
+
+ ); + }; + + return ( + 0 ? `Approval requests (${pending.length})` : 'Approval requests'} + description="Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail." + > + {error &&

{error}

} + {pending.length > 0 && ( +
+ {pending.map(renderEntry)} +
+ )} + {past.length > 0 && ( +
+ + {showPast && ( +
+ {past.map(renderEntry)} +
+ )} +
+ )} + + {source && ( +
{ if (e.target === e.currentTarget) setSource(null); }}> +
+
+ {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version} + +
+ {source.warnings && source.warnings.length > 0 && ( +
+ {source.warnings.join('; ')} +
+ )} +
+              {source.error ?? source.code ?? ( Loading)}
+            
+
+
+ )} +
+ ); +} diff --git a/app/(main)/admin/_pages/plugins.tsx b/app/(main)/admin/_pages/plugins.tsx new file mode 100644 index 000000000..536b4276f --- /dev/null +++ b/app/(main)/admin/_pages/plugins.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Upload, Loader2, Puzzle, Lock, Store, Power, PowerOff, ShieldAlert } from '@/components/icons'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; +import { adminPath } from '@/lib/admin/admin-nav'; +import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminButtonLink } from '@/components/admin/button-link'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Badge } from '@/components/admin/badge'; +import { EmptyState } from '@/components/admin/empty-state'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { ExtensionIcon } from '@/components/admin/extension-icon'; +import { PluginApprovalsPanel } from './plugin-approvals-panel'; +import { useExtensionIcons } from './use-extension-icons'; +import type { ExtensionEntry } from './extension-detail'; + +export function PluginsPage() { + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); + const [message, setMessage] = useFlash(); + const fileInputRef = useRef(null); + const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); + const [savedPolicy, setSavedPolicy] = useState({ ...DEFAULT_POLICY }); + const [savingPolicy, setSavingPolicy] = useState(false); + const iconFor = useExtensionIcons(); + + const fetchPlugins = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/plugins'); + if (res.ok) setPlugins(await res.json()); + } finally { + setLoading(false); + } + }, []); + + const fetchPolicy = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/policy'); + if (res.ok) { + const data = await res.json(); + setPolicy(data); + setSavedPolicy(data); + } + } catch { /* the defaults stay */ } + }, []); + + useEffect(() => { fetchPlugins(); fetchPolicy(); }, [fetchPlugins, fetchPolicy]); + + const features = policy.features; + const pluginsEnabled = features.pluginsEnabled ?? true; + const pluginsUploadEnabled = features.pluginsUploadEnabled ?? true; + const requirePluginApproval = features.requirePluginApproval ?? true; + const policyDirty = JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features); + useAdminUnsavedChanges('plugins', policyDirty); + + function setFeature(key: 'pluginsEnabled' | 'pluginsUploadEnabled' | 'requirePluginApproval', value: boolean) { + setPolicy((prev) => ({ ...prev, features: { ...prev.features, [key]: value } })); + setMessage(null); + } + + async function savePolicy() { + setSavingPolicy(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + if (res.ok) { + setSavedPolicy(policy); + setMessage({ type: 'success', text: 'Plugin policy saved. Users see the change on their next login.' }); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not save the plugin policy' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not save the plugin policy' }); + } finally { + setSavingPolicy(false); + } + } + + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + setMessage(null); + const formData = new FormData(); + formData.append('file', file); + try { + const res = await apiFetch('/api/admin/plugins', { method: 'POST', body: formData }); + const data = await res.json(); + if (res.ok) { + const warnings = data.warnings?.length ? ` (${data.warnings.length} warning${data.warnings.length === 1 ? '' : 's'})` : ''; + setMessage({ type: 'success', text: `"${data.plugin.name}" installed${warnings}` }); + await fetchPlugins(); + } else { + setMessage({ type: 'error', text: data.error || 'Upload failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Upload failed' }); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function setEnabled(id: string, enabled: boolean): Promise { + const res = await apiFetch('/api/admin/plugins', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, enabled }), + }); + if (res.ok) { + setPlugins((prev) => prev.map((p) => (p.id === id ? { ...p, enabled } : p))); + return true; + } + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not change the plugin' }); + return false; + } + + async function setAll(enabled: boolean) { + const targets = plugins.filter((p) => p.enabled !== enabled); + if (targets.length === 0) return; + setBulkBusy(true); + setMessage(null); + let failed = 0; + for (const p of targets) { + if (!(await setEnabled(p.id, enabled))) failed++; + } + await fetchPlugins(); + setBulkBusy(false); + setMessage(failed === 0 + ? { type: 'success', text: `${targets.length} plugin${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` } + : { type: 'error', text: `${failed} plugin${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` }); + } + + const enabledCount = plugins.filter((p) => p.enabled).length; + + return ( +
+ + + + Marketplace + + + + )} + /> + + setMessage(null)} /> + +
+ + + + {loading ? ( +
+ Loading +
+ ) : plugins.length === 0 ? ( + Browse the marketplace} + /> + ) : ( + <> + {plugins.length > 1 && ( +
+ + +
+ )} + + {plugins.map((plugin) => { + const perms = plugin.permissions ?? []; + const risky = perms.filter((p) => HIGH_RISK_PERMISSIONS.has(p)); + return ( + } + title={plugin.name} + titleExtra={( + <> + v{plugin.version} + {!plugin.enabled && Off} + {plugin.forceEnabled && }>Forced on} + {plugin.dev && Dev} + {risky.length > 0 && ( + } title={risky.join(', ')}> + {risky.length} high-risk permission{risky.length === 1 ? '' : 's'} + + )} + + )} + description={plugin.description} + meta={( + <> + by {plugin.author || 'unknown'} + {plugin.type && <> · {plugin.type}} + {perms.length > 0 && <> · {perms.length} permission{perms.length === 1 ? '' : 's'}} + {plugin.source?.kind === 'marketplace' && <> · Marketplace} + + )} + control={( + { setMessage(null); void setEnabled(plugin.id, checked); }} + ariaLabel={`${plugin.name} enabled`} + /> + )} + /> + ); + })} + + + )} +
+ + + + setFeature('pluginsEnabled', v)} /> + + + setFeature('pluginsUploadEnabled', v)} /> + + + setFeature('requirePluginApproval', v)} /> + + +
+ + setPolicy(savedPolicy)} + saveLabel="Save policy" + note="Users see the change on their next login" + /> +
+ ); +} diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx new file mode 100644 index 000000000..e1ae84045 --- /dev/null +++ b/app/(main)/admin/_pages/policy.tsx @@ -0,0 +1,629 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons'; +import { iconForName } from '@/components/icons'; +import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types'; +import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps'; +import { generateUUID, cn } from '@/lib/utils'; +import { + DEFAULT_RELAY_BASE_URL, + isValidRelayUrl, + normalizeRelayUrl, + resolveDefaultRelayUrl, +} from '@/lib/push-relays'; +import { SettingsSection, SettingItem, ToggleSwitch, SectionNav } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Badge } from '@/components/admin/badge'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { inputClass } from '@/components/admin/config-items'; + +// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized +// forward on policy load), so it is hidden from the admin UI. +const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled']; + +const FEATURE_GATE_LABELS: Partial> = { + sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' }, + settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' }, + customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' }, + templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, + calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' }, + calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' }, + contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' }, + smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' }, + externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' }, + debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, + folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, + hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, + tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' }, + filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' }, + crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, + crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, + crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, + unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' }, + accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. Mailbox passwords are stored only when the Passwords option below is on. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' }, + accountArchivePasswordsEnabled: { label: 'Account List Backup: Passwords', description: 'With Account List Backup on, offer to include the mailbox passwords of accounts signed in with a password, so a restore signs in to them by itself. They are encrypted in the browser with the backup password together with the list; the server never sees them in clear, but anyone who learns the backup password gets every mailbox in it. Accounts signed in with OAuth or a TOTP code are saved without.' }, + customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' }, + archiveActionEnabled: { label: 'Archive Action', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' }, + tagsAboveSharedEnabled: { label: 'Tags Above Shared Mailboxes', description: 'List the Tags in the sidebar above the shared mailboxes instead of below them. Turn this on when users have many shared mailboxes and work with tags: expanded, the shared mailboxes push the tags out of sight.' }, +}; + +const RESTRICTABLE_SETTINGS = [ + { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] }, + { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, + { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] }, + { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, + { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, + { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] }, + { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, + { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] }, + { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, + { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, + { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, + { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, + { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' }, + { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] }, + { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' }, + { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, + { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, + { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' }, + { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' }, + { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, +]; + +const PAGE = adminPageDef('policy'); + +const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground'; +const cardClass = 'rounded-md border border-border bg-muted/30 p-3'; +const subLabelClass = 'mt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground'; + +/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */ +function isValidDefaultAppUrl(raw: string): boolean { + try { + const parsed = new URL(raw.trim()); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host; + } catch { + return false; + } +} + +export function PolicyPage() { + const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); + const [savedPolicy, setSavedPolicy] = useState({ ...DEFAULT_POLICY }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useFlash(); + const [dirty, setDirty] = useState(false); + + useEffect(() => { fetchPolicy(); }, []); + + async function fetchPolicy() { + setLoading(true); + try { + const res = await apiFetch('/api/admin/policy'); + if (res.ok) { + const data = await res.json(); + setPolicy(data); + setSavedPolicy(data); + } + } finally { + setLoading(false); + } + } + + function toggleFeature(key: keyof FeatureGates) { + setPolicy(prev => ({ + ...prev, + features: { ...prev.features, [key]: !prev.features[key] }, + })); + setDirty(true); + setMessage(null); + } + + function setPushRelayUrl(value: string) { + setPolicy(prev => ({ ...prev, pushRelayUrl: value })); + setDirty(true); + setMessage(null); + } + + function updatePushRelay(index: number, patch: Partial) { + setPolicy(prev => ({ + ...prev, + pushRelays: (prev.pushRelays ?? []).map((relay, i) => (i === index ? { ...relay, ...patch } : relay)), + })); + setDirty(true); + setMessage(null); + } + + function addPushRelay() { + setPolicy(prev => ({ ...prev, pushRelays: [...(prev.pushRelays ?? []), { label: '', url: '' }] })); + setDirty(true); + setMessage(null); + } + + function removePushRelay(index: number) { + setPolicy(prev => { + const remaining = (prev.pushRelays ?? []).filter((_, i) => i !== index); + const removedUrl = normalizeRelayUrl((prev.pushRelays ?? [])[index]?.url); + // Dropping the relay users are defaulted to would leave a dangling + // default, so fall back to the built-in one. + const pushRelayUrl = normalizeRelayUrl(prev.pushRelayUrl) === removedUrl ? '' : prev.pushRelayUrl; + return { ...prev, pushRelays: remaining, pushRelayUrl }; + }); + setDirty(true); + setMessage(null); + } + + function togglePushRelayLocked() { + setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked })); + setDirty(true); + setMessage(null); + } + + function updateDefaultApp(index: number, patch: Partial) { + setPolicy(prev => ({ + ...prev, + defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)), + })); + setDirty(true); + setMessage(null); + } + + function addDefaultApp() { + setPolicy(prev => { + const apps = prev.defaultSidebarApps ?? []; + if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev; + return { + ...prev, + defaultSidebarApps: [ + ...apps, + { + id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`, + name: '', + url: '', + icon: 'Globe', + openMode: 'tab' as const, + showOnMobile: false, + }, + ], + }; + }); + setDirty(true); + setMessage(null); + } + + function removeDefaultApp(index: number) { + setPolicy(prev => ({ + ...prev, + defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index), + })); + setDirty(true); + setMessage(null); + } + + /** Rail order follows this list, so admins need to be able to reorder it. */ + function moveDefaultApp(index: number, delta: number) { + setPolicy(prev => { + const apps = [...(prev.defaultSidebarApps ?? [])]; + const target = index + delta; + if (target < 0 || target >= apps.length) return prev; + [apps[index], apps[target]] = [apps[target], apps[index]]; + return { ...prev, defaultSidebarApps: apps }; + }); + setDirty(true); + setMessage(null); + } + + function toggleLocked(settingKey: string) { + setPolicy(prev => { + const existing = prev.restrictions[settingKey] || {}; + const newRestrictions = { ...prev.restrictions }; + if (existing.locked) { + delete newRestrictions[settingKey]; + } else { + newRestrictions[settingKey] = { ...existing, locked: true }; + } + return { ...prev, restrictions: newRestrictions }; + }); + setDirty(true); + setMessage(null); + } + + function toggleHidden(settingKey: string) { + setPolicy(prev => { + const existing = prev.restrictions[settingKey] || {}; + const newRestrictions = { ...prev.restrictions }; + newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden }; + if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) { + delete newRestrictions[settingKey]; + } + return { ...prev, restrictions: newRestrictions }; + }); + setDirty(true); + setMessage(null); + } + + function discard() { + setPolicy(savedPolicy); + setDirty(false); + setMessage(null); + } + + async function handleSave() { + setSaving(true); + setMessage(null); + + try { + const res = await apiFetch('/api/admin/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + + if (res.ok) { + setSavedPolicy(policy); + setMessage({ type: 'success', text: 'Policy saved. Users see the change on their next login.' }); + setDirty(false); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not save the policy' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not save the policy' }); + } finally { + setSaving(false); + } + } + + useAdminUnsavedChanges('policy', dirty); + + const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; + const defaultRelayUrl = resolveDefaultRelayUrl(policy); + const defaultSidebarApps = policy.defaultSidebarApps ?? []; + + return ( +
+ + + setMessage(null)} /> + + {loading ? ( +
+ Loading +
+ ) : ( +
+ + + + {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]) + .filter(key => !EXCLUDED_FEATURE_GATES.includes(key)) + .map(key => { + const meta = FEATURE_GATE_LABELS[key]; + if (!meta) return null; + const { label, description } = meta; + return ( + + toggleFeature(key)} /> + + ); + })} + + + +
+
+ Relays +

+ Mark one as the default. Relays without a valid URL are not offered to users. +

+
+ +
+ +
+
+
+
+ Bulwark relay + built-in +
+

{DEFAULT_RELAY_BASE_URL}

+
+ +
+
+ + {(policy.pushRelays ?? []).map((relay, index) => ( +
+
+
+ + updatePushRelay(index, { label: e.target.value })} + placeholder="Company relay" + className={cn(inputClass, 'w-full')} + /> +
+
+ +
+ updatePushRelay(index, { url: e.target.value })} + placeholder="https://notifications.relay.example.com" + className={cn(inputClass, 'w-full')} + /> + +
+
+
+ +
+ ))} + + + + +
+ + +
+
+ Apps +

+ Entries without a name or a valid http(s) URL are dropped when saving. Inline apps + are framed inside the webmail, so the target must allow being embedded. +

+
+ +
+ + {defaultSidebarApps.length === 0 && ( +

+ No default apps. Users only see the apps they add themselves. +

+ )} + + {defaultSidebarApps.map((app, index) => { + const AppIcon = iconForName(app.icon); + const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url); + return ( +
+
+
+ + updateDefaultApp(index, { name: e.target.value })} + placeholder="Intranet" + className={cn(inputClass, 'w-full')} + /> +
+
+ + updateDefaultApp(index, { url: e.target.value })} + placeholder="https://intranet.example.com" + className={cn(inputClass, 'w-full', urlInvalid && 'border-destructive')} + /> + {urlInvalid && ( +

Enter a valid http or https URL

+ )} +
+
+ +
+ updateDefaultApp(index, { icon: e.target.value })} + placeholder="world" + className={cn(inputClass, 'w-full')} + /> + + {AppIcon ? : ?} + +
+
+
+ +
+
+ + +
+ +
+ + + +
+
+
+ ); + })} +
+ + + {categories.map(category => ( +
+

{category}

+ {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { + const restriction = policy.restrictions[setting.key] || {}; + return ( + +
+ + +
+
+ ); + })} +
+ ))} +
+
+ )} + + +
+ ); +} diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx new file mode 100644 index 000000000..987ea6333 --- /dev/null +++ b/app/(main)/admin/_pages/settings.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2 } from '@/components/icons'; +import { apiFetch } from '@/lib/browser-navigation'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { SettingsSection, SectionNav } from '@/components/settings/settings-section'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items'; +import { JmapServersSection } from './_jmap-servers-section'; +import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; + +interface ConfigEntry { + value?: unknown; + source: 'admin' | 'env' | 'default'; + hasValue?: boolean; +} + +const PAGE = adminPageDef('settings'); + +export function SettingsPage() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useFlash(); + + useEffect(() => { + fetchConfig(); + }, []); + + async function fetchConfig() { + setLoading(true); + const res = await apiFetch('/api/admin/config'); + if (res.ok) { + setConfig(await res.json()); + } + setLoading(false); + } + + function handleChange(key: string, value: unknown) { + setEdits(prev => ({ ...prev, [key]: value })); + setMessage(null); + } + + function currentValue(key: string): unknown { + if (key in edits) return edits[key]; + return config[key]?.value; + } + + async function handleSave() { + if (Object.keys(edits).length === 0) return; + setSaving(true); + setMessage(null); + + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edits), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' }); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + async function handleRevert(key: string) { + const res = await apiFetch('/api/admin/config', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) { + setEdits(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + await fetchConfig(); + setMessage({ type: 'success', text: `${key} reverted to default` }); + } + } + + const hasEdits = Object.keys(edits).length > 0; + + useAdminUnsavedChanges('settings', hasEdits); + + if (loading) { + return ( +
+ +
+ Loading +
+
+ ); + } + + const jmapServers = currentValue('jmapServers'); + + return ( +
+ + + setMessage(null)} /> + +
+ + + + + + + {!!currentValue('allowCustomJmapEndpoint') && ( + + CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked. + + )} + + + + + + + + + + + + + + + + + handleChange('jmapServers', next)} + onRevert={() => handleRevert('jmapServers')} + /> + {Array.isArray(jmapServers) && (jmapServers as JmapServerEntry[]).length > 0 && ( + + CORS warning: Each JMAP server must allow this webmail's origin in its Access-Control-Allow-Origin header, or browser requests will be blocked. + + )} + + + + + + + Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail's base path is added to it. The editor must also allow this webmail as a WOPI host. + + + + + + + + + + + +
+ + setEdits({})} + note="Takes effect on the next page load" + /> +
+ ); +} diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx new file mode 100644 index 000000000..a745ac45c --- /dev/null +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, Send, ExternalLink } from '@/components/icons'; +import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { ConfigNote, inputClass } from '@/components/admin/config-items'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { cn } from '@/lib/utils'; + +interface TelemetryStatus { + consent: 'pending' | 'on' | 'off'; + consentSource: 'env' | 'file'; + endpoint: string; + defaultEndpoint: string; + consentedAt: string | null; + lastSentAt: string | null; + nextScheduledAt: string | null; + payloadPreview: Record; + accountCounts: { total: number; active7d: number }; +} + +const PAGE = adminPageDef('telemetry'); + +function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 0) return new Date(iso).toLocaleString(); + const m = Math.floor(d / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} hours ago`; + const days = Math.floor(h / 24); + return `${days} days ago`; +} + +export function TelemetryPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [endpointDraft, setEndpointDraft] = useState(''); + const [message, setMessage] = useFlash(); + + const endpointDirty = status !== null && endpointDraft !== status.endpoint; + useAdminUnsavedChanges('telemetry', endpointDirty); + + async function refresh(): Promise { + setLoading(true); + try { + const r = await apiFetch('/api/admin/telemetry'); + if (!r.ok) throw new Error('failed to load'); + const data = (await r.json()) as TelemetryStatus; + setStatus(data); + setEndpointDraft(data.endpoint); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { void refresh(); }, []); + + async function setConsent(consent: 'on' | 'off'): Promise { + setBusy('consent'); + setMessage(null); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set-consent', consent }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + setMessage({ type: 'error', text: j.error ?? 'failed' }); + } + await refresh(); + } finally { setBusy(null); } + } + + async function saveEndpoint(): Promise { + setBusy('endpoint'); + setMessage(null); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }), + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + setMessage({ type: 'error', text: j.error ?? 'failed' }); + } else { + setMessage({ type: 'success', text: 'Endpoint saved.' }); + } + await refresh(); + } finally { setBusy(null); } + } + + async function sendNow(): Promise { + setBusy('send'); + setMessage(null); + try { + const r = await apiFetch('/api/admin/telemetry', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'send-now' }), + }); + const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string }; + setMessage(j.ok + ? { type: 'success', text: `sent (HTTP ${j.status ?? '?'})` } + : { type: 'error', text: `failed: ${j.error ?? 'unknown'}` }); + await refresh(); + } finally { setBusy(null); } + } + + const header = ; + + if (loading || !status) { + return ( +
+ {header} +
+ Loading +
+
+ ); + } + + const envOverridden = status.consentSource === 'env'; + const isOn = status.consent === 'on'; + + const consentDescription = [ + status.consent === 'pending' && 'Initialising - no heartbeats sent yet.', + status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!', + status.consent === 'off' && 'Heartbeats are off (default).', + envOverridden && 'Locked by the BULWARK_TELEMETRY env var.', + ].filter(Boolean).join(' '); + + return ( +
+ {header} + + setMessage(null)} /> + +
+ + Bulwark can send one anonymous heartbeat per day so we can see how many instances are + running, on what platforms, and which features they use. It's off by + default; one click below enables it and helps us make the product better. No + email addresses, no hostnames, no IPs are sent.{' '} + + Full schema and policy + + + + + + void setConsent(checked ? 'on' : 'off')} + /> + + + {timeAgo(status.lastSentAt)} + + + {timeAgo(status.nextScheduledAt)} + + + {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'} + + + + + + {status.accountCounts?.total ?? 0} + + + {status.accountCounts?.active7d ?? 0} + + + + + + { setEndpointDraft(e.target.value); setMessage(null); }} + placeholder={status.defaultEndpoint} + aria-label="Collector URL" + autoComplete="off" + spellCheck={false} + className={cn(inputClass, 'w-full sm:w-80')} + /> + + + + +
+ +
+
+            {JSON.stringify(status.payloadPreview, null, 2)}
+          
+
+
+ + void saveEndpoint()} + onDiscard={() => setEndpointDraft(status.endpoint)} + saveLabel="Save endpoint" + /> +
+ ); +} diff --git a/app/(main)/admin/_pages/themes.tsx b/app/(main)/admin/_pages/themes.tsx new file mode 100644 index 000000000..e50896f7a --- /dev/null +++ b/app/(main)/admin/_pages/themes.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Upload, Loader2, SwatchBook, Lock, Store, Power, PowerOff } from '@/components/icons'; +import type { ColorMode, SettingsPolicy, ThemePolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; +import { adminPath, adminPageDef } from '@/lib/admin/admin-nav'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { BUILTIN_THEMES } from '@/lib/builtin-themes'; +import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminButtonLink } from '@/components/admin/button-link'; +import { AdminList, AdminListRow } from '@/components/admin/list'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { Badge } from '@/components/admin/badge'; +import { EmptyState } from '@/components/admin/empty-state'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { inputClass } from '@/components/admin/config-items'; +import { cn } from '@/lib/utils'; +import { ExtensionIcon } from '@/components/admin/extension-icon'; +import { useExtensionIcons } from './use-extension-icons'; +import type { ExtensionEntry } from './extension-detail'; + +// Derive from the single source of truth so newly added built-in themes show +// up here automatically (was previously a hardcoded subset — see #496). +const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name })); + +const PAGE = adminPageDef('themes'); + +/** Policy as the page edits it: the theme policy is always fully merged. */ +function withThemeDefaults(data: SettingsPolicy): SettingsPolicy { + return { + ...data, + themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) }, + }; +} + +export function ThemesPage() { + const [themes, setThemes] = useState([]); + const iconFor = useExtensionIcons(); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); + const [message, setMessage] = useFlash(); + const fileInputRef = useRef(null); + const [policy, setPolicy] = useState(() => withThemeDefaults({ ...DEFAULT_POLICY })); + const [savedPolicy, setSavedPolicy] = useState(() => withThemeDefaults({ ...DEFAULT_POLICY })); + const [savingPolicy, setSavingPolicy] = useState(false); + + const fetchThemes = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/themes'); + if (res.ok) setThemes(await res.json()); + } finally { + setLoading(false); + } + }, []); + + const fetchPolicy = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/policy'); + if (res.ok) { + const data = withThemeDefaults(await res.json()); + setPolicy(data); + setSavedPolicy(data); + } + } catch { /* the defaults stay */ } + }, []); + + useEffect(() => { fetchThemes(); fetchPolicy(); }, [fetchThemes, fetchPolicy]); + + const policyDirty = + JSON.stringify(policy.themePolicy) !== JSON.stringify(savedPolicy.themePolicy) || + JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features); + useAdminUnsavedChanges('themes', policyDirty); + + function setFeature(key: 'themesEnabled' | 'userThemesEnabled', value: boolean) { + setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: value } })); + setMessage(null); + } + + function toggleBuiltinTheme(themeId: string) { + setPolicy(prev => { + const disabled = prev.themePolicy?.disabledBuiltinThemes || []; + const isDisabled = disabled.includes(themeId); + return { + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + disabledBuiltinThemes: isDisabled + ? disabled.filter((id: string) => id !== themeId) + : [...disabled, themeId], + }, + }; + }); + setMessage(null); + } + + function toggleAdminTheme(themeId: string) { + setPolicy(prev => { + const disabled = prev.themePolicy?.disabledThemes || []; + const isDisabled = disabled.includes(themeId); + return { + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + disabledThemes: isDisabled + ? disabled.filter((id: string) => id !== themeId) + : [...disabled, themeId], + }, + }; + }); + setMessage(null); + } + + function setDefaultTheme(themeId: string | null) { + setPolicy(prev => ({ + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + defaultThemeId: themeId, + }, + })); + setMessage(null); + } + + function setColorModePolicy(patch: Partial>) { + setPolicy(prev => ({ + ...prev, + themePolicy: { + ...DEFAULT_THEME_POLICY, + ...prev.themePolicy, + ...patch, + }, + })); + setMessage(null); + } + + async function savePolicy() { + setSavingPolicy(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + if (res.ok) { + setSavedPolicy(policy); + setMessage({ type: 'success', text: 'Theme policy saved. Users see the change on their next login.' }); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not save the theme policy' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not save the theme policy' }); + } finally { + setSavingPolicy(false); + } + } + + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + setMessage(null); + const formData = new FormData(); + formData.append('file', file); + try { + const res = await apiFetch('/api/admin/themes', { method: 'POST', body: formData }); + const data = await res.json(); + if (res.ok) { + const warnings = data.warnings?.length ? ` (${data.warnings.length} warning${data.warnings.length === 1 ? '' : 's'})` : ''; + setMessage({ type: 'success', text: `"${data.theme.name}" installed${warnings}` }); + await fetchThemes(); + } else { + setMessage({ type: 'error', text: data.error || 'Upload failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Upload failed' }); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function setEnabled(id: string, enabled: boolean): Promise { + const res = await apiFetch('/api/admin/themes', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, enabled }), + }); + if (res.ok) { + setThemes(prev => prev.map(t => (t.id === id ? { ...t, enabled } : t))); + return true; + } + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not change the theme' }); + return false; + } + + async function setAll(enabled: boolean) { + const targets = themes.filter(t => t.enabled !== enabled); + if (targets.length === 0) return; + setBulkBusy(true); + setMessage(null); + let failed = 0; + for (const t of targets) { + if (!(await setEnabled(t.id, enabled))) failed++; + } + await fetchThemes(); + setBulkBusy(false); + setMessage(failed === 0 + ? { type: 'success', text: `${targets.length} theme${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` } + : { type: 'error', text: `${failed} theme${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` }); + } + + const enabledCount = themes.filter(t => t.enabled).length; + const themesEnabled = policy.features.themesEnabled ?? true; + const userThemesEnabled = policy.features.userThemesEnabled ?? true; + const lockColorMode = policy.themePolicy?.lockColorMode === true; + const disabledBuiltin = policy.themePolicy?.disabledBuiltinThemes || []; + const disabledInstalled = policy.themePolicy?.disabledThemes || []; + + return ( +
+ + + + Marketplace + + + + )} + /> + + setMessage(null)} /> + +
+ + {loading ? ( +
+ Loading +
+ ) : themes.length === 0 ? ( + Browse the marketplace} + /> + ) : ( + <> + {themes.length > 1 && ( +
+ + +
+ )} + + {themes.map(theme => ( + } + title={theme.name} + titleExtra={( + <> + v{theme.version} + {!theme.enabled && Off} + {theme.forceEnabled && }>Forced on} + + )} + description={theme.description} + meta={( + <> + by {theme.author || 'unknown'} + {theme.variants && theme.variants.length > 0 && <> · {theme.variants.join(', ')}} + {theme.source?.kind === 'marketplace' && <> · Marketplace} + + )} + control={( + { setMessage(null); void setEnabled(theme.id, checked); }} + ariaLabel={`${theme.name} enabled`} + /> + )} + /> + ))} + + + )} +
+ + + + setFeature('themesEnabled', v)} /> + + + setFeature('userThemesEnabled', v)} /> + + + + + + + + + setColorModePolicy({ lockColorMode: v })} /> + + + + + {BUILTIN_THEME_OPTIONS.map(theme => ( + + toggleBuiltinTheme(theme.id)} /> + + ))} + {themes.length > 0 && ( + <> +

Installed

+ {themes.map(theme => ( + + toggleAdminTheme(theme.id)} /> + + ))} + + )} +
+
+ + setPolicy(savedPolicy)} + saveLabel="Save policy" + note="Users see the change on their next login" + /> +
+ ); +} diff --git a/app/(main)/admin/_pages/use-extension-icons.ts b/app/(main)/admin/_pages/use-extension-icons.ts new file mode 100644 index 000000000..de4978d59 --- /dev/null +++ b/app/(main)/admin/_pages/use-extension-icons.ts @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { MarketplaceListing } from '@/lib/admin/marketplace-directory'; +import type { ExtensionEntry } from './extension-detail'; + +/** + * Brand icons for installed plugins and themes. An installed bundle carries + * no image (the manifest's `icon` is marketplace media the directory + * serves), so the icons come from the directory's listing, matched by slug. + * Fetched once per session; an unreachable directory just means the + * generic icons stay. + */ +let cache: Map | null = null; +let pending: Promise> | null = null; + +async function loadIcons(): Promise> { + const icons = new Map(); + for (let page = 1; page <= 5; page++) { + const res = await apiFetch(`/api/admin/marketplace?page=${page}&perPage=100`); + if (!res.ok) break; + const data = await res.json() as { data?: MarketplaceListing[]; meta?: { total?: number; perPage?: number } }; + for (const ext of data.data ?? []) { + if (ext.iconUrl) icons.set(ext.slug, ext.iconUrl); + } + const seen = page * (data.meta?.perPage ?? 100); + if (!data.data?.length || seen >= (data.meta?.total ?? 0)) break; + } + return icons; +} + +export function useExtensionIcons(): (entry: Pick) => string | null { + const [icons, setIcons] = useState | null>(cache); + useEffect(() => { + if (cache) return; + let cancelled = false; + pending ??= loadIcons().then((map) => { cache = map; return map; }).catch(() => new Map()); + pending.then((map) => { if (!cancelled) setIcons(map); }); + return () => { cancelled = true; }; + }, []); + return (entry) => { + if (!icons) return null; + const slug = entry.source?.kind === 'marketplace' ? entry.source.slug : entry.id; + return icons.get(slug) ?? null; + }; +} + +/** Test hook: forget what was fetched. */ +export function resetExtensionIconCache(): void { + cache = null; + pending = null; +} diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx new file mode 100644 index 000000000..e9dcd6d0c --- /dev/null +++ b/app/(main)/admin/_pages/version.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Loader2, + RefreshCw, + CheckCircle2, + AlertTriangle, + ShieldAlert, + ExternalLink, +} from '@/components/icons'; +import { SettingsSection, SettingItem } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { Badge, type BadgeTone } from '@/components/admin/badge'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { adminPageDef } from '@/lib/admin/admin-nav'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; + +interface VersionAdminStatus { + current: string; + build: string; + endpoint: string; + defaultEndpoint: string; + disabledByEnv: boolean; + lastCheckedAt: string | null; + lastSuccessAt: string | null; + nextScheduledAt: string | null; + status: UpdateStatus | null; +} + +const PAGE = adminPageDef('version'); + +function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 0) return new Date(iso).toLocaleString(); + const m = Math.floor(d / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} hours ago`; + return `${Math.floor(h / 24)} days ago`; +} + +function severityChip(severity: UpdateSeverity): { label: string; tone: BadgeTone; Icon: typeof CheckCircle2 } { + switch (severity) { + case 'security': + return { label: 'Security update', tone: 'danger', Icon: ShieldAlert }; + case 'deprecated': + return { label: 'Deprecated', tone: 'danger', Icon: ShieldAlert }; + case 'normal': + return { label: 'Update available', tone: 'warning', Icon: AlertTriangle }; + case 'unknown': + return { label: 'Unknown', tone: 'muted', Icon: AlertTriangle }; + case 'none': + default: + return { label: 'Up to date', tone: 'success', Icon: CheckCircle2 }; + } +} + +export function VersionPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [message, setMessage] = useFlash(); + + async function refresh(): Promise { + setLoading(true); + try { + const r = await apiFetch('/api/admin/version'); + if (!r.ok) throw new Error('failed to load'); + setData((await r.json()) as VersionAdminStatus); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { void refresh(); }, []); + + async function checkNow(): Promise { + setChecking(true); + setMessage(null); + try { + const r = await apiFetch('/api/admin/version', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'check-now' }), + }); + const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string }; + setMessage(j.ok + ? { type: 'success', text: 'Update check completed.' } + : { type: 'error', text: `Failed: ${j.error ?? 'unknown'}` }); + await refresh(); + } finally { + setChecking(false); + } + } + + const header = ( + void checkNow()}> + {checking ? : } + Check now + + )} + /> + ); + + if (loading || !data) { + return ( +
+ {header} +
+ Loading +
+
+ ); + } + + const status = data.status; + const chip = severityChip(status?.severity ?? 'none'); + const ChipIcon = chip.Icon; + const releaseUrl = status?.url ?? null; + const newer = status?.latest && status.latest !== data.current ? status.latest : null; + + return ( +
+ {header} + + setMessage(null)} /> + +
+ + + }>{chip.label} + + + {data.current} + + {newer && ( + + {releaseUrl ? ( + + {newer} + + ) : ( + {newer} + )} + + )} + {status?.advisory && ( + + {status.advisory} + + )} + + + + + {timeAgo(data.lastCheckedAt)} + + + {timeAgo(data.lastSuccessAt)} + + + {timeAgo(data.nextScheduledAt)} + + {status?.checkedAt && ( + + {new Date(status.checkedAt).toLocaleString()} + + )} + + + + + + {data.endpoint} + + + + + {data.disabledByEnv ? 'Yes' : 'No'} + + + +
+
+ ); +} diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx deleted file mode 100644 index 2b3b977b1..000000000 --- a/app/(main)/admin/_tabs/auth.tsx +++ /dev/null @@ -1,407 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons'; -import { apiFetch } from '@/lib/browser-navigation'; -import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes'; - -interface ConfigEntry { - // Sensitive keys (sessionSecret, oauthClientSecret) come back with - // `value` omitted and `hasValue` set instead - the server never echoes - // the raw secret to the client. - value?: unknown; - source: 'admin' | 'env' | 'default'; - hasValue?: boolean; -} - -export function AuthTab() { - const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - useEffect(() => { fetchConfig(); }, []); - - async function fetchConfig() { - setLoading(true); - const res = await apiFetch('/api/admin/config'); - if (res.ok) setConfig(await res.json()); - setLoading(false); - } - - function handleChange(key: string, value: unknown) { - setEdits(prev => ({ ...prev, [key]: value })); - setMessage(null); - } - - function currentValue(key: string): unknown { - if (key in edits) return edits[key]; - return config[key]?.value; - } - - async function handleSave() { - if (Object.keys(edits).length === 0) return; - setSaving(true); - setMessage(null); - - const res = await apiFetch('/api/admin/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(edits), - }); - - if (res.ok) { - setMessage({ type: 'success', text: 'Authentication settings saved.' }); - setEdits({}); - await fetchConfig(); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save' }); - } - setSaving(false); - } - - async function handleRevert(key: string) { - const res = await apiFetch('/api/admin/config', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key }), - }); - if (res.ok) { - setEdits(prev => { const next = { ...prev }; delete next[key]; return next; }); - await fetchConfig(); - } - } - - const [setupRunning, setSetupRunning] = useState(false); - const [setupOpen, setSetupOpen] = useState(false); - const [setupOrigin, setSetupOrigin] = useState(''); - const [setupIssuer, setSetupIssuer] = useState(''); - const [setupOauthOnly, setSetupOauthOnly] = useState(false); - - function openSetupDialog() { - if (typeof window === 'undefined') return; - const origin = window.location.origin; - const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || ''; - setSetupOrigin(origin); - setSetupIssuer(jmapUrl || origin); - setSetupOauthOnly(currentValue('oauthOnly') === true); - setSetupOpen(true); - } - - async function handleAutoSetup() { - setSetupRunning(true); - setMessage(null); - try { - const res = await apiFetch('/api/admin/oauth/setup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - origin: setupOrigin.trim().replace(/\/+$/, ''), - issuerUrl: setupIssuer.trim().replace(/\/+$/, ''), - oauthOnly: setupOauthOnly, - }), - }); - const data = await res.json(); - if (res.ok) { - setMessage({ - type: 'success', - text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`, - }); - setEdits({}); - setSetupOpen(false); - await fetchConfig(); - } else { - const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; - setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail }); - } - } catch (err) { - setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' }); - } finally { - setSetupRunning(false); - } - } - - const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, '')); - const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, '')); - - const hasEdits = Object.keys(edits).length > 0; - - if (loading) { - return
Loading...
; - } - - return ( -
-
-
-

Authentication

-

OAuth, SSO, and session configuration

-
- {hasEdits && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - -
-
-
-
- -

Auto-configure OAuth (Stalwart)

-
-

- Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. - Requires your Stalwart account to have admin permissions. -

-
- -
-
- - {setupOpen && ( -
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} - > -
-
-

Auto-configure OAuth

-

- Verify the URLs below before continuing. The webmail and Stalwart can live on different domains. -

-
-
-
- - setSetupOrigin(e.target.value)} - disabled={setupRunning} - placeholder="https://webmail.example.com" - className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> -

- Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart. -

- {!setupOriginValid && setupOrigin.length > 0 && ( -

Must be like https://host with no path.

- )} -
-
- - setSetupIssuer(e.target.value)} - disabled={setupRunning} - placeholder="https://mail.example.com" - className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> -

- Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL. -

- {!setupIssuerValid && setupIssuer.length > 0 && ( -

Must be like https://host with no path.

- )} -
- -
-
- - -
-
-
- )} - -
- - - - - - - - -
- -
- - - -
- -
- - - -
-
- ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
-

{title}

-
-
{children}
-
- ); -} - -function SourceBadge({ source }: { source?: string }) { - if (!source || source === 'default') return null; - return ( - - {source} - - ); -} - -function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: { - label: string; description?: string; configKey: string; value: string; source?: string; - onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string; -}) { - return ( -
-
-
- {label} - -
- {description &&

{description}

} -
-
- onChange(configKey, e.target.value)} placeholder={placeholder} - className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {source === 'admin' && ( - - )} -
-
- ); -} - -function Toggle({ label, description, configKey, value, source, onChange, onRevert }: { - label: string; description?: string; configKey: string; value: boolean; source?: string; - onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; -}) { - return ( -
-
-
- {label} - -
- {description &&

{description}

} -
-
- - {source === 'admin' && ( - - )} -
-
- ); -} - -function Select({ label, description, configKey, value, source, options, optionLabels, onChange, onRevert }: { - label: string; description?: string; configKey: string; value: string; source?: string; options: string[]; - optionLabels?: Record; - onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; -}) { - return ( -
-
-
- {label} - -
- {description &&

{description}

} -
-
- - {source === 'admin' && ( - - )} -
-
- ); -} diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx deleted file mode 100644 index c93da7367..000000000 --- a/app/(main)/admin/_tabs/branding.tsx +++ /dev/null @@ -1,721 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useRef, useState } from 'react'; -import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons'; -import { apiFetch, withBasePath } from '@/lib/browser-navigation'; -import { - BRANDING_OVERRIDE_KEYS, - parseDomainBranding, - type BrandingOverrideKey, - type DomainBrandingEntry, -} from '@/lib/admin/domain-branding'; - -interface ConfigEntry { - value?: unknown; - source: 'admin' | 'env' | 'default'; - hasValue?: boolean; -} - -const IMAGE_FIELDS = [ - { key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' }, - { key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, - { key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, - { key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, - { key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, -] as const; - -const TEXT_FIELDS = [ - { key: 'loginCompanyName', label: 'Company Name' }, - { key: 'loginImprintUrl', label: 'Imprint URL' }, - { key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' }, - { key: 'loginWebsiteUrl', label: 'Company Website URL' }, -] as const; - -const PWA_IMAGE_FIELDS = [ - { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, - { key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' }, - { key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' }, -] as const; - -const PWA_TEXT_FIELDS = [ - { key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' }, - { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, -] as const; - -const PWA_COLOR_FIELDS = [ - { key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' }, - { key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' }, -] as const; - -// Accepts exact hosts and one-level wildcards (e.g. *.example.com). -const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; -// Tighter rule for uploads: wildcards can only point to externally-hosted -// URLs, since we'd have no concrete subdomain to serve a file from. -const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; - -export function BrandingTab() { - const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [uploading, setUploading] = useState(null); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const [selectedHost, setSelectedHost] = useState(null); - const [addingHost, setAddingHost] = useState(false); - const [newHostInput, setNewHostInput] = useState(''); - const [newHostError, setNewHostError] = useState(null); - const fileInputRefs = useRef>({}); - - useEffect(() => { - fetchConfig(); - }, []); - - const domainEntries = useMemo( - () => parseDomainBranding(config['domainBranding']?.value), - [config], - ); - - // Drop selection if the host disappeared from the config (e.g. concurrent edit). - useEffect(() => { - if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) { - setSelectedHost(null); - setEdits({}); - } - }, [domainEntries, selectedHost]); - - async function fetchConfig() { - setLoading(true); - const res = await apiFetch('/api/admin/config'); - if (res.ok) setConfig(await res.json()); - setLoading(false); - } - - function selectedEntry(): DomainBrandingEntry | null { - if (!selectedHost) return null; - return domainEntries.find(e => e.host === selectedHost) ?? null; - } - - function handleChange(key: string, value: string) { - setEdits(prev => ({ ...prev, [key]: value })); - setMessage(null); - } - - function currentValue(key: string): string { - if (key in edits) return edits[key]; - if (selectedHost) { - const entry = selectedEntry(); - return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? ''; - } - return (config[key]?.value as string) ?? ''; - } - - function isOverriddenInScope(key: string): boolean { - if (selectedHost) { - const entry = selectedEntry(); - const v = entry?.[key as BrandingOverrideKey]; - return typeof v === 'string' && v.length > 0; - } - return config[key]?.source === 'admin'; - } - - const isUploadedFile = (key: string): boolean => { - const val = currentValue(key); - return val.startsWith('/api/admin/branding/'); - }; - - function buildUpdatedDomainBranding(merge: Record): DomainBrandingEntry[] { - if (!selectedHost) return domainEntries; - const next = domainEntries.slice(); - const idx = next.findIndex(e => e.host === selectedHost); - const base: DomainBrandingEntry = - idx === -1 ? { host: selectedHost } : { ...next[idx] }; - const writable = base as unknown as Record; - for (const [key, value] of Object.entries(merge)) { - if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue; - if (typeof value === 'string' && value.length > 0) { - writable[key] = value; - } else { - delete writable[key]; - } - } - if (idx === -1) next.push(base); - else next[idx] = base; - return next; - } - - async function handleSave() { - if (Object.keys(edits).length === 0) return; - setSaving(true); - setMessage(null); - - const payload = selectedHost - ? { domainBranding: buildUpdatedDomainBranding(edits) } - : edits; - - const res = await apiFetch('/api/admin/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (res.ok) { - setMessage({ - type: 'success', - text: selectedHost - ? `Branding for ${selectedHost} updated. Changes visible on next page load.` - : 'Branding updated. Changes visible on next page load.', - }); - setEdits({}); - await fetchConfig(); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save' }); - } - setSaving(false); - } - - async function handleUpload(slot: string, file: File) { - if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) { - setMessage({ - type: 'error', - text: 'Wildcard hosts cannot upload files. Enter a URL instead.', - }); - return; - } - setUploading(slot); - setMessage(null); - - const formData = new FormData(); - formData.append('file', file); - formData.append('slot', slot); - if (selectedHost) formData.append('host', selectedHost); - - const res = await apiFetch('/api/admin/branding', { - method: 'POST', - body: formData, - }); - - if (res.ok) { - const data = await res.json(); - setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` }); - setEdits(prev => { - const next = { ...prev }; - delete next[slot]; - return next; - }); - // Refresh from server so domainBranding entries reflect the upload. - await fetchConfig(); - void data; - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Upload failed' }); - } - setUploading(null); - } - - async function handleDeleteUpload(slot: string) { - setMessage(null); - - const body: { slot: string; host?: string } = { slot }; - if (selectedHost) body.host = selectedHost; - - const res = await apiFetch('/api/admin/branding', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (res.ok) { - setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' }); - setEdits(prev => { - const next = { ...prev }; - delete next[slot]; - return next; - }); - await fetchConfig(); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to remove' }); - } - } - - async function handleRevert(key: string) { - if (selectedHost) { - // Domain scope: drop the field from the entry and PATCH the array. - const updated = buildUpdatedDomainBranding({ [key]: '' }); - const res = await apiFetch('/api/admin/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ domainBranding: updated }), - }); - if (res.ok) { - setEdits(prev => { - const next = { ...prev }; - delete next[key]; - return next; - }); - await fetchConfig(); - } - return; - } - // Default scope: revert via DELETE /api/admin/config - const res = await apiFetch('/api/admin/config', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key }), - }); - if (res.ok) { - setEdits(prev => { - const next = { ...prev }; - delete next[key]; - return next; - }); - await fetchConfig(); - } - } - - async function handleAddDomain() { - const host = newHostInput.trim().toLowerCase().replace(/\.+$/, ''); - if (!host) { - setNewHostError('Enter a hostname'); - return; - } - if (!HOST_RE.test(host)) { - setNewHostError('Invalid hostname. Use foo.example.com or *.example.com'); - return; - } - if (domainEntries.some(e => e.host === host)) { - setNewHostError('A branding entry for this host already exists'); - return; - } - setNewHostError(null); - - const next: DomainBrandingEntry[] = [...domainEntries, { host }]; - const res = await apiFetch('/api/admin/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ domainBranding: next }), - }); - if (res.ok) { - setNewHostInput(''); - setAddingHost(false); - setSelectedHost(host); - setEdits({}); - await fetchConfig(); - } else { - const data = await res.json(); - setNewHostError(data.error || 'Failed to add domain'); - } - } - - async function handleDeleteDomain() { - if (!selectedHost) return; - if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) { - return; - } - const next = domainEntries.filter(e => e.host !== selectedHost); - const res = await apiFetch('/api/admin/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ domainBranding: next }), - }); - if (res.ok) { - setSelectedHost(null); - setEdits({}); - await fetchConfig(); - setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` }); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to remove domain' }); - } - } - - function handleScopeChange(host: string | null) { - if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return; - setSelectedHost(host); - setEdits({}); - setMessage(null); - } - - const hasEdits = Object.keys(edits).length > 0; - const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); - - if (loading) { - return
Loading...
; - } - - return ( -
-
-
-

Branding

-

Customize logos, favicon, and company information

-
- {hasEdits && ( - - )} -
- - {/* Scope picker */} -
-
- -

Scope

-
-
-
- - {domainEntries.map(entry => ( - - ))} - {!addingHost && ( - - )} -
- {addingHost && ( -
- { setNewHostInput(e.target.value); setNewHostError(null); }} - onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }} - placeholder="mail.example.com or *.example.com" - 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" - /> - - - {newHostError && {newHostError}} -
- )} - {selectedHost ? ( -
-

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

- -
- ) : ( -

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

- )} -
-
- - {message && ( -
- {message.text} -
- )} - -
-
-

Images & Logos

-

Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)

-
-
- {IMAGE_FIELDS.map(field => ( -
-
-
- - {isOverriddenInScope(field.key) && ( - - {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} - - )} -
-
- 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" - /> - { fileInputRefs.current[field.key] = el; }} - type="file" - accept={field.accept} - className="hidden" - onChange={(e) => { - const file = e.target.files?.[0]; - if (file) handleUpload(field.key, file); - e.target.value = ''; - }} - /> - - {isUploadedFile(field.key) && ( - - )} - {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( - - )} -
-
- {currentValue(field.key) && ( -
- -
- {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> -
-
- )} -
- ))} -
-
- -
-
-

Progressive Web App

-

Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.

-
-
- {PWA_IMAGE_FIELDS.map(field => ( -
-
-
- - {isOverriddenInScope(field.key) && ( - - {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} - - )} -
-
- 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" - /> - { fileInputRefs.current[field.key] = el; }} - type="file" - accept={field.accept} - className="hidden" - onChange={(e) => { - const file = e.target.files?.[0]; - if (file) handleUpload(field.key, file); - e.target.value = ''; - }} - /> - - {isUploadedFile(field.key) && ( - - )} - {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( - - )} -
-
- {currentValue(field.key) && ( -
- -
- {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> -
-
- )} -
- ))} - {PWA_TEXT_FIELDS.map(field => ( -
-
- - {isOverriddenInScope(field.key) && ( - - {selectedHost ? 'domain' : 'admin'} - - )} -
-
- handleChange(field.key, e.target.value)} - placeholder={field.placeholder} - className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> - {isOverriddenInScope(field.key) && ( - - )} -
-
- ))} - {PWA_COLOR_FIELDS.map(field => { - const value = currentValue(field.key) || field.defaultValue; - return ( -
-
- - {isOverriddenInScope(field.key) && ( - - {selectedHost ? 'domain' : 'admin'} - - )} -
-
- 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" - /> - 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) && ( - - )} -
-
- ); - })} -
-
- -
-
-

Company Information

-
-
- {TEXT_FIELDS.map(field => ( -
-
- - {isOverriddenInScope(field.key) && ( - - {selectedHost ? 'domain' : 'admin'} - - )} -
-
- handleChange(field.key, e.target.value)} - placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} - className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> - {isOverriddenInScope(field.key) && ( - - )} -
-
- ))} -
-
-
- ); -} diff --git a/app/(main)/admin/_tabs/dashboard.tsx b/app/(main)/admin/_tabs/dashboard.tsx deleted file mode 100644 index 3356c8d9e..000000000 --- a/app/(main)/admin/_tabs/dashboard.tsx +++ /dev/null @@ -1,194 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { AlertTriangle } from '@/components/icons'; -import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; -import type { AuditEntry } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface AdminStatus { - enabled: boolean; - authenticated: boolean; - lastLogin: string | null; - passwordChangedAt: string | null; -} - -interface ConfigData { - appName?: string; - jmapServerUrl?: string; - settingsSyncEnabled?: boolean; - stalwartFeaturesEnabled?: boolean; - oauthEnabled?: boolean; - devMode?: boolean; -} - -export function DashboardTab() { - const [status, setStatus] = useState(null); - const [recentActivity, setRecentActivity] = useState([]); - const [config, setConfig] = useState(null); - const [, setConfigSources] = useState | null>(null); - const [warnings, setWarnings] = useState([]); - const [pluginCount, setPluginCount] = useState(0); - const [themeCount, setThemeCount] = useState(0); - const [policyRuleCount, setPolicyRuleCount] = useState(0); - const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null); - - useEffect(() => { - fetchDashboardData(); - }, []); - - async function fetchDashboardData() { - const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([ - apiFetch('/api/admin/auth'), - apiFetch('/api/admin/audit?limit=10'), - apiFetch('/api/config'), - apiFetch('/api/admin/config'), - apiFetch('/api/admin/plugins').catch(() => null), - apiFetch('/api/admin/themes').catch(() => null), - apiFetch('/api/admin/policy').catch(() => null), - apiFetch('/api/admin/telemetry').catch(() => null), - ]); - - if (statusRes.ok) setStatus(await statusRes.json()); - if (auditRes.ok) { - const data = await auditRes.json(); - setRecentActivity(data.entries || []); - } - let configData: ConfigData | null = null; - if (configRes.ok) { - configData = await configRes.json(); - setConfig(configData); - } - - if (pluginRes?.ok) { - const plugins = await pluginRes.json(); - setPluginCount(Array.isArray(plugins) ? plugins.length : 0); - } - if (themeRes?.ok) { - const themes = await themeRes.json(); - setThemeCount(Array.isArray(themes) ? themes.length : 0); - } - if (policyRes?.ok) { - const policy = await policyRes.json(); - const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0; - const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0; - setPolicyRuleCount(restrictionCount + disabledGates); - } - if (telemetryRes?.ok) { - const telemetry = await telemetryRes.json(); - if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') { - setAccountCounts(telemetry.accountCounts); - } - } - - const w: string[] = []; - if (adminConfigRes.ok) { - const sources = await adminConfigRes.json(); - setConfigSources(sources); - const sessionSecret = sources?.sessionSecret; - // Server redacts the raw value for sensitive keys; rely on hasValue, - // which is false when unset or matching a known placeholder default. - if (!sessionSecret?.hasValue) { - w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.'); - } - const adminPassword = sources?.adminPassword; - if (adminPassword?.value && adminPassword.source === 'env') { - w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.'); - } - } - setWarnings(w); - } - - const jmapUrl = config?.jmapServerUrl || '-'; - const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-'; - - return ( -
- {warnings.map((msg, i) => ( -
- -

{msg}

-
- ))} - - - - {config?.appName || '-'} - - - {jmapHostname} - - - - {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} - - - - - - - {}} disabled /> - - - {}} disabled /> - - - {}} disabled /> - - - {}} disabled /> - - - - - - {accountCounts?.total ?? '-'} - - - {accountCounts?.active7d ?? '-'} - - - - - - {pluginCount} - - - {themeCount} - - - {policyRuleCount} - - - - - {recentActivity.length === 0 ? ( -
- No activity recorded yet -
- ) : ( - recentActivity.map((entry, i) => ( - -
- {entry.ip} - {new Date(entry.ts).toLocaleString()} -
-
- )) - )} -
-
- ); -} - -function formatDetail(detail: Record): string { - if (!detail || Object.keys(detail).length === 0) return ''; - if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`; - if (detail.reason) return String(detail.reason); - if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`; - return JSON.stringify(detail).slice(0, 80); -} diff --git a/app/(main)/admin/_tabs/logs.tsx b/app/(main)/admin/_tabs/logs.tsx deleted file mode 100644 index 181891e25..000000000 --- a/app/(main)/admin/_tabs/logs.tsx +++ /dev/null @@ -1,174 +0,0 @@ -'use client'; - -import { useEffect, useState, useCallback } from 'react'; -import { RefreshCw } from '@/components/icons'; -import type { AuditEntry } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -export function LogsTab() { - const [entries, setEntries] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(true); - const [actionFilter, setActionFilter] = useState(''); - const limit = 50; - - const fetchLogs = useCallback(async () => { - setLoading(true); - const params = new URLSearchParams({ page: String(page), limit: String(limit) }); - if (actionFilter) params.set('action', actionFilter); - - const res = await apiFetch(`/api/admin/audit?${params}`); - if (res.ok) { - const data = await res.json(); - setEntries(data.entries || []); - setTotal(data.total || 0); - } - setLoading(false); - }, [page, actionFilter]); - - useEffect(() => { fetchLogs(); }, [fetchLogs]); - - const totalPages = Math.max(1, Math.ceil(total / limit)); - - return ( -
-
-
-

Audit Log

-

{total} total entries

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

- Page {page} of {totalPages} -

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

Marketplace

-

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

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

{error}

-

- Start the extension directory server on the configured port -

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

No extensions found

- {query && ( -

- Try a different search term -

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

- {extension.description} -

- - {extension.tags && extension.tags.length > 0 && ( -
- {extension.tags.slice(0, 3).map(tag => ( - - {tag} - - ))} -
- )} - -
-
- - - {extension.totalDownloads.toLocaleString()} - - {extension.permissions && extension.permissions.length > 0 && ( - - {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} - - )} -
- - - Preview - -
- - -
- {extension.installed && updateAvailable ? ( - - ) : extension.installed ? ( - - - Installed - - ) : versionMismatch ? ( - - - Requires v{extension.minAppVersion}+ - - ) : ( - - )} -
-
- ); -} diff --git a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx deleted file mode 100644 index 51fe96758..000000000 --- a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx +++ /dev/null @@ -1,192 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons'; -import { apiFetch } from '@/lib/browser-navigation'; -import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; - -interface ApprovalEntry { - pluginId: string; - bundleHash: string; - status: 'pending' | 'approved' | 'denied'; - manifest: { - name?: string; - version?: string; - author?: string; - description?: string; - permissions?: string[]; - httpOrigins?: string[]; - apiPostPaths?: string[]; - }; - requestedBy: string; - requestedAt: string; - decidedAt?: string; -} - -interface SourceView { - entry: ApprovalEntry; - code?: string; - warnings?: string[]; - error?: string; -} - -const STATUS_STYLE: Record = { - pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400', - approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400', - denied: 'bg-muted text-muted-foreground', -}; - -/** - * Plugins users installed themselves and asked to have approved. The admin - * decides on the package the user uploaded: its manifest and code are read - * on the server, and the approval is pinned to that exact package. - */ -export function PluginApprovalsPanel() { - const [entries, setEntries] = useState(null); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(null); - const [source, setSource] = useState(null); - - const load = useCallback(async () => { - try { - const res = await apiFetch('/api/admin/plugin-approvals'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json() as { entries: ApprovalEntry[] }; - setEntries([...data.entries].sort((a, b) => - Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt))); - setError(null); - } catch (e) { - setError(`Could not load approval requests (${(e as Error).message})`); - } - }, []); - - useEffect(() => { void load(); }, [load]); - - async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') { - const key = `${entry.pluginId}:${entry.bundleHash}`; - setBusy(key); - try { - const res = decision === 'revoke' - ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' }) - : await apiFetch('/api/admin/plugin-approvals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - await load(); - } catch (e) { - setError(`Could not save the decision (${(e as Error).message})`); - } finally { - setBusy(null); - } - } - - async function viewSource(entry: ApprovalEntry) { - setSource({ entry }); - try { - const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); - setSource({ entry, code: data.code, warnings: data.warnings }); - } catch (e) { - setSource({ entry, error: (e as Error).message }); - } - } - - const pending = entries?.filter((e) => e.status === 'pending').length ?? 0; - - return ( -
-
-
- -

Approval Requests

- {pending > 0 && ( - {pending} waiting - )} -
-

Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.

-
- - {error &&
{error}
} - - {entries === null ? ( -
- ) : entries.length === 0 ? ( -
No requests yet
- ) : ( -
- {entries.map((entry) => { - const key = `${entry.pluginId}:${entry.bundleHash}`; - const perms = entry.manifest.permissions ?? []; - return ( -
-
- {entry.manifest.name ?? entry.pluginId} - v{entry.manifest.version} - {entry.status} -
- {entry.manifest.description &&

{entry.manifest.description}

} -
- {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()} -
- {perms.length > 0 && ( -
- {perms.map((p) => ( - {p} - ))} -
- )} - {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && ( -
- Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')} -
- )} -
- - {entry.status !== 'approved' && ( - - )} - {entry.status !== 'denied' && ( - - )} - {entry.status !== 'pending' && ( - - )} -
-
- ); - })} -
- )} - - {source && ( -
{ if (e.target === e.currentTarget) setSource(null); }}> -
-
- {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version} - -
- {source.warnings && source.warnings.length > 0 && ( -
- {source.warnings.join('; ')} -
- )} -
-              {source.error ?? source.code ?? 'Loading…'}
-            
-
-
- )} -
- ); -} diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx deleted file mode 100644 index d396c086a..000000000 --- a/app/(main)/admin/_tabs/plugin-config-panel.tsx +++ /dev/null @@ -1,314 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons'; -import { apiFetch } from '@/lib/browser-navigation'; -import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; -import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; - -interface ConfigField { - type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; - label: string; - description?: string; - required?: boolean; - default?: unknown; - placeholder?: string; - options?: { label: string; value: string }[]; -} - -interface PluginConfig { - [key: string]: unknown; -} - -interface PluginInfo { - id: string; - name: string; - description: string; - version: string; - author: string; - type: string; - permissions: string[]; - enabled: boolean; - configSchema?: Record; -} - -interface Props { - pluginId: string; - onBack: () => void; -} - -export function PluginConfigPanel({ pluginId, onBack }: Props) { - const [plugin, setPlugin] = useState(null); - const [config, setConfig] = useState({}); - const [formValues, setFormValues] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [revealSecrets, setRevealSecrets] = useState>({}); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - useEffect(() => { - let cancelled = false; - async function fetchData() { - setLoading(true); - try { - const [pluginsRes, configRes] = await Promise.all([ - apiFetch('/api/admin/plugins'), - apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), - ]); - if (cancelled) return; - - if (pluginsRes.ok) { - const plugins: PluginInfo[] = await pluginsRes.json(); - setPlugin(plugins.find(p => p.id === pluginId) || null); - } - - if (configRes.ok) { - setConfig(await configRes.json()); - } - } finally { - if (!cancelled) setLoading(false); - } - } - fetchData(); - return () => { cancelled = true; }; - }, [pluginId]); - - useEffect(() => { - if (!plugin?.configSchema) return; - const initial: Record = {}; - for (const [key, field] of Object.entries(plugin.configSchema)) { - const stored = config[key]; - if (stored !== undefined && stored !== null) { - initial[key] = String(stored); - } else if (field.default !== undefined) { - initial[key] = String(field.default); - } else { - initial[key] = ''; - } - } - setFormValues(initial); - }, [plugin, config]); - - async function handleSaveAll() { - if (!plugin?.configSchema) return; - setSaving(true); - setMessage(null); - - for (const [key, field] of Object.entries(plugin.configSchema)) { - if (field.required && !formValues[key]?.trim()) { - setMessage({ type: 'error', text: `"${field.label}" is required` }); - setSaving(false); - return; - } - } - - try { - let hasError = false; - for (const [key, field] of Object.entries(plugin.configSchema)) { - const newVal = formValues[key] ?? ''; - const oldVal = config[key] !== undefined ? String(config[key]) : ''; - - if (newVal === oldVal) continue; - if (field.type === 'secret' && !newVal && config[key]) continue; - - let value: unknown = newVal; - if (field.type === 'boolean') value = newVal === 'true'; - else if (field.type === 'number') value = Number(newVal); - - if (!newVal && !field.required) { - const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key }), - }); - if (res.ok) { - setConfig(prev => { const next = { ...prev }; delete next[key]; return next; }); - } else { - hasError = true; - } - continue; - } - - const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key, value }), - }); - if (res.ok) { - setConfig(prev => ({ ...prev, [key]: value })); - } else { - hasError = true; - } - } - - setMessage(hasError - ? { type: 'error', text: 'Some settings failed to save' } - : { type: 'success', text: 'Configuration saved' } - ); - } catch { - setMessage({ type: 'error', text: 'Failed to save configuration' }); - } finally { - setSaving(false); - } - } - - if (loading) { - return ( -
- - Loading... -
- ); - } - - if (!plugin) { - return ( -
- -

Plugin not found: {pluginId}

-
- ); - } - - const schema = plugin.configSchema; - const hasSchema = schema && Object.keys(schema).length > 0; - - return ( -
-
- -
-

- - {plugin.name} Configuration -

-

- v{plugin.version} by {plugin.author} -

-
-
- - {message && ( -
- {message.text} -
- )} - - {hasSchema ? ( -
-
-

Settings

-
-
- {Object.entries(schema).map(([key, field]) => ( -
- - {field.description && ( -

{field.description}

- )} - - {field.type === 'boolean' ? ( - - ) : field.type === 'select' && field.options ? ( - - ) : field.type === 'secret' ? ( -
- setFormValues(prev => ({ ...prev, [key]: e.target.value }))} - placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')} - className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" - /> - -
- ) : ( - 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" - /> - )} -
- ))} - - -
-
- ) : ( -
-

This plugin does not declare any configuration settings.

-
- )} - - -
- ); -} - -/** - * Renders the plugin's own `admin-plugin-page` slot, if the plugin offers - * one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']` - * and the host gives it a dedicated iframe inside the admin panel. - */ -function PluginAdminSection({ pluginId }: { pluginId: string }) { - const offers = usePluginSlotOffers('admin-plugin-page'); - const offer = offers.find((o) => o.pluginId === pluginId); - if (!offer) return null; - return ( -
-
- Plugin admin panel -
- -
- ); -} diff --git a/app/(main)/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx deleted file mode 100644 index 908820b7e..000000000 --- a/app/(main)/admin/_tabs/plugins.tsx +++ /dev/null @@ -1,465 +0,0 @@ -'use client'; - -import { useEffect, useState, useRef } from 'react'; -import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from '@/components/icons'; -import type { SettingsPolicy } from '@/lib/admin/types'; -import { DEFAULT_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; -import { PluginConfigPanel } from './plugin-config-panel'; -import { PluginApprovalsPanel } from './plugin-approvals-panel'; - -interface PluginEntry { - id: string; - name: string; - version: string; - author: string; - description: string; - type: string; - enabled: boolean; - forceEnabled?: boolean; - permissions: string[]; - installedAt: string; - updatedAt: string; -} - -export function PluginsTab() { - const [plugins, setPlugins] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const fileInputRef = useRef(null); - const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); - const [policyDirty, setPolicyDirty] = useState(false); - const [savingPolicy, setSavingPolicy] = useState(false); - const [configuringId, setConfiguringId] = useState(null); - - useEffect(() => { fetchPlugins(); fetchPolicy(); }, []); - - async function fetchPolicy() { - try { - const res = await apiFetch('/api/admin/policy'); - if (res.ok) { - const data = await res.json(); - setPolicy(data); - } - } catch { /* ignore */ } - } - - function togglePluginsEnabled() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled }, - })); - setPolicyDirty(true); - setMessage(null); - } - - function togglePluginsUploadEnabled() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled }, - })); - setPolicyDirty(true); - setMessage(null); - } - - function toggleRequirePluginApproval() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval }, - })); - setPolicyDirty(true); - setMessage(null); - } - - async function handleSavePolicy() { - setSavingPolicy(true); - setMessage(null); - try { - const res = await apiFetch('/api/admin/policy', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(policy), - }); - if (res.ok) { - setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' }); - setPolicyDirty(false); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save policy' }); - } - } catch { - setMessage({ type: 'error', text: 'Failed to save policy' }); - } finally { - setSavingPolicy(false); - } - } - - async function fetchPlugins() { - setLoading(true); - try { - const res = await apiFetch('/api/admin/plugins'); - if (res.ok) setPlugins(await res.json()); - } finally { - setLoading(false); - } - } - - async function handleUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - - setUploading(true); - setMessage(null); - - const formData = new FormData(); - formData.append('file', file); - - try { - const res = await apiFetch('/api/admin/plugins', { - method: 'POST', - body: formData, - }); - - const data = await res.json(); - if (res.ok) { - const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; - setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` }); - await fetchPlugins(); - } else { - setMessage({ type: 'error', text: data.error || 'Upload failed' }); - } - } catch { - setMessage({ type: 'error', text: 'Upload failed' }); - } finally { - setUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ''; - } - } - - async function togglePlugin(id: string, enabled: boolean) { - setMessage(null); - const res = await apiFetch('/api/admin/plugins', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, enabled }), - }); - - if (res.ok) { - setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p)); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Update failed' }); - } - } - - async function toggleForceEnabled(id: string, forceEnabled: boolean) { - setMessage(null); - const body: Record = { id, forceEnabled }; - if (forceEnabled) body.enabled = true; - - const res = await apiFetch('/api/admin/plugins', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (res.ok) { - setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p)); - setPolicy(prev => { - const current = prev.forceEnabledPlugins || []; - return { - ...prev, - forceEnabledPlugins: forceEnabled - ? [...current.filter(pid => pid !== id), id] - : current.filter(pid => pid !== id), - }; - }); - setPolicyDirty(true); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Update failed' }); - } - } - - async function forceEnableAll() { - setMessage(null); - const disabled = plugins.filter(p => !p.enabled); - if (disabled.length === 0) { - setMessage({ type: 'success', text: 'All plugins are already enabled' }); - return; - } - let failed = 0; - for (const p of disabled) { - const res = await apiFetch('/api/admin/plugins', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: p.id, enabled: true }), - }); - if (!res.ok) failed++; - } - setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p)); - if (failed === 0) { - await fetchPlugins(); - setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` }); - } else { - await fetchPlugins(); - setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` }); - } - } - - async function forceDisableAll() { - setMessage(null); - const enabled = plugins.filter(p => p.enabled); - if (enabled.length === 0) { - setMessage({ type: 'success', text: 'All plugins are already disabled' }); - return; - } - let failed = 0; - for (const p of enabled) { - const res = await apiFetch('/api/admin/plugins', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: p.id, enabled: false }), - }); - if (!res.ok) failed++; - } - if (failed === 0) { - await fetchPlugins(); - setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` }); - } else { - await fetchPlugins(); - setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` }); - } - } - - async function deletePlugin(id: string, name: string) { - if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return; - - setMessage(null); - const res = await apiFetch('/api/admin/plugins', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - - if (res.ok) { - setPlugins(prev => prev.filter(p => p.id !== id)); - setMessage({ type: 'success', text: `Plugin "${name}" removed` }); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Delete failed' }); - } - } - - if (configuringId) { - return { setConfiguringId(null); fetchPlugins(); }} />; - } - - if (loading) { - return
Loading...
; - } - - const pluginsEnabled = policy.features.pluginsEnabled ?? true; - const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; - const requirePluginApproval = policy.features.requirePluginApproval ?? true; - - return ( -
-
-
-

Plugins

-

Manage plugins and plugin policy for all users

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

Plugin Policy

-
-

Control plugin availability for users

-
-
-
-
- Plugins Enabled -

Allow the plugin system to load and run plugins for users

-
- -
- -
-
- User Plugin Uploads -

Allow users to upload plugin ZIP files in Settings

-
- -
- -
-
- Require Admin Approval -

User-uploaded plugins must be approved by an admin before they can be enabled

-
- -
- - {plugins.length > 0 && ( -
-
- Force Enable / Disable All -

Bulk toggle all deployed plugins at once

-
-
- - -
-
- )} -
-
- - {pluginsUploadEnabled && } - -
-
-
- -

Deployed Plugins

-
-

Admin-uploaded plugins for all users

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

No plugins installed

-

Upload a plugin ZIP file to get started

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

{plugin.description}

- )} -
- by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()} -
- {plugin.permissions.length > 0 && ( -
- - - Permissions: {plugin.permissions.join(', ')} - -
- )} -
- -
- - - - -
-
- ))} -
- )} -
-
- ); -} diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx deleted file mode 100644 index e42478bac..000000000 --- a/app/(main)/admin/_tabs/policy.tsx +++ /dev/null @@ -1,620 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Save, Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons'; -import { iconForName } from '@/components/icons'; -import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types'; -import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; -import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps'; -import { generateUUID } from '@/lib/utils'; -import { - DEFAULT_RELAY_BASE_URL, - isValidRelayUrl, - normalizeRelayUrl, - resolveDefaultRelayUrl, -} from '@/lib/push-relays'; - -// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized -// forward on policy load), so it is hidden from the admin UI. -const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled']; - -const FEATURE_GATE_LABELS: Partial> = { - sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' }, - settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' }, - customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' }, - templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, - calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' }, - calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' }, - contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' }, - smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' }, - externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' }, - debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, - folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, - hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, - tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' }, - filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' }, - crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, - crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, - crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, - unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' }, - accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. No mailbox passwords are stored. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' }, - customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' }, -}; - -const RESTRICTABLE_SETTINGS = [ - { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] }, - { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, - { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] }, - { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, - { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, - { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] }, - { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, - { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] }, - { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, - { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, - { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, - { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, - { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' }, - { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] }, - { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' }, - { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, - { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, - { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' }, - { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' }, - { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, -]; - -/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */ -function isValidDefaultAppUrl(raw: string): boolean { - try { - const parsed = new URL(raw.trim()); - return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host; - } catch { - return false; - } -} - -export function PolicyTab() { - const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const [dirty, setDirty] = useState(false); - - useEffect(() => { fetchPolicy(); }, []); - - async function fetchPolicy() { - setLoading(true); - try { - const res = await apiFetch('/api/admin/policy'); - if (res.ok) { - const data = await res.json(); - setPolicy(data); - } - } finally { - setLoading(false); - } - } - - function toggleFeature(key: keyof FeatureGates) { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, [key]: !prev.features[key] }, - })); - setDirty(true); - setMessage(null); - } - - function setPushRelayUrl(value: string) { - setPolicy(prev => ({ ...prev, pushRelayUrl: value })); - setDirty(true); - setMessage(null); - } - - function updatePushRelay(index: number, patch: Partial) { - setPolicy(prev => ({ - ...prev, - pushRelays: (prev.pushRelays ?? []).map((relay, i) => (i === index ? { ...relay, ...patch } : relay)), - })); - setDirty(true); - setMessage(null); - } - - function addPushRelay() { - setPolicy(prev => ({ ...prev, pushRelays: [...(prev.pushRelays ?? []), { label: '', url: '' }] })); - setDirty(true); - setMessage(null); - } - - function removePushRelay(index: number) { - setPolicy(prev => { - const remaining = (prev.pushRelays ?? []).filter((_, i) => i !== index); - const removedUrl = normalizeRelayUrl((prev.pushRelays ?? [])[index]?.url); - // Dropping the relay users are defaulted to would leave a dangling - // default, so fall back to the built-in one. - const pushRelayUrl = normalizeRelayUrl(prev.pushRelayUrl) === removedUrl ? '' : prev.pushRelayUrl; - return { ...prev, pushRelays: remaining, pushRelayUrl }; - }); - setDirty(true); - setMessage(null); - } - - function togglePushRelayLocked() { - setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked })); - setDirty(true); - setMessage(null); - } - - function updateDefaultApp(index: number, patch: Partial) { - setPolicy(prev => ({ - ...prev, - defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)), - })); - setDirty(true); - setMessage(null); - } - - function addDefaultApp() { - setPolicy(prev => { - const apps = prev.defaultSidebarApps ?? []; - if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev; - return { - ...prev, - defaultSidebarApps: [ - ...apps, - { - id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`, - name: '', - url: '', - icon: 'Globe', - openMode: 'tab' as const, - showOnMobile: false, - }, - ], - }; - }); - setDirty(true); - setMessage(null); - } - - function removeDefaultApp(index: number) { - setPolicy(prev => ({ - ...prev, - defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index), - })); - setDirty(true); - setMessage(null); - } - - /** Rail order follows this list, so admins need to be able to reorder it. */ - function moveDefaultApp(index: number, delta: number) { - setPolicy(prev => { - const apps = [...(prev.defaultSidebarApps ?? [])]; - const target = index + delta; - if (target < 0 || target >= apps.length) return prev; - [apps[index], apps[target]] = [apps[target], apps[index]]; - return { ...prev, defaultSidebarApps: apps }; - }); - setDirty(true); - setMessage(null); - } - - function toggleLocked(settingKey: string) { - setPolicy(prev => { - const existing = prev.restrictions[settingKey] || {}; - const newRestrictions = { ...prev.restrictions }; - if (existing.locked) { - delete newRestrictions[settingKey]; - } else { - newRestrictions[settingKey] = { ...existing, locked: true }; - } - return { ...prev, restrictions: newRestrictions }; - }); - setDirty(true); - setMessage(null); - } - - function toggleHidden(settingKey: string) { - setPolicy(prev => { - const existing = prev.restrictions[settingKey] || {}; - const newRestrictions = { ...prev.restrictions }; - newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden }; - if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) { - delete newRestrictions[settingKey]; - } - return { ...prev, restrictions: newRestrictions }; - }); - setDirty(true); - setMessage(null); - } - - async function handleSave() { - setSaving(true); - setMessage(null); - - const res = await apiFetch('/api/admin/policy', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(policy), - }); - - if (res.ok) { - setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' }); - setDirty(false); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save' }); - } - setSaving(false); - } - - if (loading) { - return
Loading...
; - } - - const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; - const defaultRelayUrl = resolveDefaultRelayUrl(policy); - const defaultSidebarApps = policy.defaultSidebarApps ?? []; - - return ( -
-
-
-

User Policy

-

Control which features and settings users can access

-
- {dirty && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - -
-
-

Feature Gates

-

Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.

-
-
- {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]) - .filter(key => !EXCLUDED_FEATURE_GATES.includes(key)) - .map(key => { - const meta = FEATURE_GATE_LABELS[key]; - if (!meta) return null; - const { label, description } = meta; - const enabled = policy.features[key]; - return ( -
-
- {label} -

{description}

-
- -
- ); - })} -
-
- -
-
-

Push Relays

-

- Relays users can pick from in notification settings. The built-in Bulwark relay is always - offered; add your own here. Users choose from this list - they cannot enter a URL. -

-
-
-
-
- Relays -

- Mark one as the default. Relays without a valid URL are not offered to users. -

-
- -
- -
-
-
-
- Bulwark relay - - built-in - -
-

{DEFAULT_RELAY_BASE_URL}

-
- -
-
- - {(policy.pushRelays ?? []).map((relay, index) => ( -
-
-
- - 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" - /> -
-
- -
- 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" - /> - -
-
-
- -
- ))} - - -
-
- -
-
-

Default Sidebar Apps

-

- Apps every user gets in the navigation rail without configuring anything. They appear - above the user's own apps and cannot be edited or removed by users. Independent of - the Sidebar Apps feature gate above, which only governs user-added apps. -

-
-
-
-
- Apps -

- Entries without a name or a valid http(s) URL are dropped when saving. Inline apps - are framed inside the webmail, so the target must allow being embedded. -

-
- -
- - {defaultSidebarApps.length === 0 && ( -

- No default apps. Users only see the apps they add themselves. -

- )} - - {defaultSidebarApps.map((app, index) => { - const AppIcon = iconForName(app.icon); - const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url); - return ( -
-
-
- - 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" - /> -
-
- - updateDefaultApp(index, { url: e.target.value })} - placeholder="https://intranet.example.com" - className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${urlInvalid ? 'border-destructive' : 'border-input'}`} - /> - {urlInvalid && ( -

Enter a valid http or https URL

- )} -
-
- -
- updateDefaultApp(index, { icon: e.target.value })} - placeholder="world" - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> - - {AppIcon ? : ?} - -
-
-
- -
-
- - -
- -
- - - -
-
-
- ); - })} -
-
- - {categories.map(category => ( -
-
-

{category}

-
-
- {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { - const restriction = policy.restrictions[setting.key] || {}; - return ( -
- {setting.label} -
- - -
-
- ); - })} -
-
- ))} -
- ); -} diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx deleted file mode 100644 index 9870d4ffa..000000000 --- a/app/(main)/admin/_tabs/settings.tsx +++ /dev/null @@ -1,297 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Save, RotateCcw, Loader2 } from '@/components/icons'; -import { apiFetch } from '@/lib/browser-navigation'; -import { JmapServersSection } from './_jmap-servers-section'; -import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; - -interface ConfigEntry { - value?: unknown; - source: 'admin' | 'env' | 'default'; - hasValue?: boolean; -} - -export function SettingsTab() { - const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - - useEffect(() => { - fetchConfig(); - }, []); - - async function fetchConfig() { - setLoading(true); - const res = await apiFetch('/api/admin/config'); - if (res.ok) { - setConfig(await res.json()); - } - setLoading(false); - } - - function handleChange(key: string, value: unknown) { - setEdits(prev => ({ ...prev, [key]: value })); - setMessage(null); - } - - function currentValue(key: string): unknown { - if (key in edits) return edits[key]; - return config[key]?.value; - } - - async function handleSave() { - if (Object.keys(edits).length === 0) return; - setSaving(true); - setMessage(null); - - const res = await apiFetch('/api/admin/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(edits), - }); - - if (res.ok) { - setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' }); - setEdits({}); - await fetchConfig(); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save' }); - } - setSaving(false); - } - - async function handleRevert(key: string) { - const res = await apiFetch('/api/admin/config', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key }), - }); - if (res.ok) { - setEdits(prev => { - const next = { ...prev }; - delete next[key]; - return next; - }); - await fetchConfig(); - setMessage({ type: 'success', text: `${key} reverted to default` }); - } - } - - const hasEdits = Object.keys(edits).length > 0; - - if (loading) { - return
Loading...
; - } - - return ( -
-
-
-

Server Settings

-

General server configuration

-
- {hasEdits && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - - - - - - {!!currentValue('allowCustomJmapEndpoint') && ( -
-

- CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked. -

-
- )} - - - -
- - - - - - - - - - - - - handleChange('jmapServers', next)} - onRevert={() => handleRevert('jmapServers')} - /> - {Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && ( -
-

- CORS warning: Each JMAP server must allow this webmail's origin in its Access-Control-Allow-Origin header, or browser requests will be blocked. -

-
- )} -
- - - - -
-

- Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail's base path is added to it. The editor must also allow this webmail as a WOPI host. -

-
-
- - - - - - - - - -
- ); -} - -function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
-

{title}

-
-
- {children} -
-
- ); -} - -function SourceBadge({ source }: { source?: string }) { - if (!source || source === 'default') return null; - return ( - - {source} - - ); -} - -function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: { - label: string; configKey: string; value: string; source?: string; - onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string; -}) { - return ( -
-
- - -
-
- onChange(configKey, e.target.value)} - placeholder={placeholder} - className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - /> - {source === 'admin' && ( - - )} -
-
- ); -} - -function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: { - label: string; description?: string; configKey: string; value: boolean; source?: string; - onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; -}) { - return ( -
-
-
- {label} - -
- {description &&

{description}

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

Anonymous Usage Stats

-

- Bulwark can send one anonymous heartbeat per day so we can see how many instances are - running, on what platforms, and which features they use. It's off by - default; one click below enables it and helps us make the product better. No - email addresses, no hostnames, no IPs are sent.{' '} - - Full schema and policy - -

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

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

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

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

-
- setEndpointDraft(e.target.value)} - placeholder={status.defaultEndpoint} - className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background" - /> - -
-
- -
-
-
-
Payload preview
-
- Exactly what the next heartbeat would send from this install, right now. -
-
- -
- {sendResult && ( -
- {sendResult.ok ? : } - {sendResult.msg} -
- )} -
-          {JSON.stringify(status.payloadPreview, null, 2)}
-        
-
-
- ); -} diff --git a/app/(main)/admin/_tabs/themes.tsx b/app/(main)/admin/_tabs/themes.tsx deleted file mode 100644 index 95ce4cf78..000000000 --- a/app/(main)/admin/_tabs/themes.tsx +++ /dev/null @@ -1,548 +0,0 @@ -'use client'; - -import { useEffect, useState, useRef } from 'react'; -import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from '@/components/icons'; -import type { SettingsPolicy } from '@/lib/admin/types'; -import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; -import { BUILTIN_THEMES } from '@/lib/builtin-themes'; - -// Derive from the single source of truth so newly added built-in themes show -// up here automatically (was previously a hardcoded subset — see #496). -const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name })); - -interface ThemeEntry { - id: string; - name: string; - version: string; - author: string; - description: string; - variants: string[]; - enabled: boolean; - forceEnabled?: boolean; - installedAt: string; - updatedAt: string; -} - -export function ThemesTab() { - const [themes, setThemes] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const fileInputRef = useRef(null); - const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); - const [policyDirty, setPolicyDirty] = useState(false); - const [savingPolicy, setSavingPolicy] = useState(false); - - useEffect(() => { fetchThemes(); fetchPolicy(); }, []); - - async function fetchPolicy() { - try { - const res = await apiFetch('/api/admin/policy'); - if (res.ok) { - const data = await res.json(); - setPolicy({ - ...data, - themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) }, - }); - } - } catch { /* ignore */ } - } - - function toggleThemesEnabled() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, themesEnabled: !prev.features.themesEnabled }, - })); - setPolicyDirty(true); - setMessage(null); - } - - function toggleUserThemeUploads() { - setPolicy(prev => ({ - ...prev, - features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled }, - })); - setPolicyDirty(true); - setMessage(null); - } - - function toggleBuiltinTheme(themeId: string) { - setPolicy(prev => { - const disabled = prev.themePolicy?.disabledBuiltinThemes || []; - const isDisabled = disabled.includes(themeId); - return { - ...prev, - themePolicy: { - ...DEFAULT_THEME_POLICY, - ...prev.themePolicy, - disabledBuiltinThemes: isDisabled - ? disabled.filter((id: string) => id !== themeId) - : [...disabled, themeId], - }, - }; - }); - setPolicyDirty(true); - setMessage(null); - } - - function toggleAdminTheme(themeId: string) { - setPolicy(prev => { - const disabled = prev.themePolicy?.disabledThemes || []; - const isDisabled = disabled.includes(themeId); - return { - ...prev, - themePolicy: { - ...DEFAULT_THEME_POLICY, - ...prev.themePolicy, - disabledThemes: isDisabled - ? disabled.filter((id: string) => id !== themeId) - : [...disabled, themeId], - }, - }; - }); - setPolicyDirty(true); - setMessage(null); - } - - function setDefaultTheme(themeId: string | null) { - setPolicy(prev => ({ - ...prev, - themePolicy: { - ...DEFAULT_THEME_POLICY, - ...prev.themePolicy, - defaultThemeId: themeId, - }, - })); - setPolicyDirty(true); - setMessage(null); - } - - async function handleSavePolicy() { - setSavingPolicy(true); - setMessage(null); - try { - const res = await apiFetch('/api/admin/policy', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(policy), - }); - if (res.ok) { - setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' }); - setPolicyDirty(false); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save policy' }); - } - } catch { - setMessage({ type: 'error', text: 'Failed to save policy' }); - } finally { - setSavingPolicy(false); - } - } - - async function fetchThemes() { - setLoading(true); - try { - const res = await apiFetch('/api/admin/themes'); - if (res.ok) setThemes(await res.json()); - } finally { - setLoading(false); - } - } - - async function handleUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - - setUploading(true); - setMessage(null); - - const formData = new FormData(); - formData.append('file', file); - - try { - const res = await apiFetch('/api/admin/themes', { - method: 'POST', - body: formData, - }); - - const data = await res.json(); - if (res.ok) { - const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; - setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` }); - await fetchThemes(); - } else { - setMessage({ type: 'error', text: data.error || 'Upload failed' }); - } - } catch { - setMessage({ type: 'error', text: 'Upload failed' }); - } finally { - setUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ''; - } - } - - async function toggleTheme(id: string, enabled: boolean) { - setMessage(null); - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, enabled }), - }); - - if (res.ok) { - setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t)); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Update failed' }); - } - } - - async function toggleForceEnabled(id: string, forceEnabled: boolean) { - setMessage(null); - const body: Record = { id, forceEnabled }; - if (forceEnabled) body.enabled = true; - - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (res.ok) { - setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t)); - setPolicy(prev => { - const current = prev.forceEnabledThemes || []; - return { - ...prev, - forceEnabledThemes: forceEnabled - ? [...current.filter(tid => tid !== id), id] - : current.filter(tid => tid !== id), - }; - }); - setPolicyDirty(true); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Update failed' }); - } - } - - async function forceEnableAll() { - setMessage(null); - const disabled = themes.filter(t => !t.enabled); - if (disabled.length === 0) { - setMessage({ type: 'success', text: 'All themes are already enabled' }); - return; - } - let failed = 0; - for (const t of disabled) { - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: t.id, enabled: true }), - }); - if (!res.ok) failed++; - } - if (failed === 0) { - await fetchThemes(); - setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` }); - } else { - await fetchThemes(); - setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` }); - } - } - - async function forceDisableAll() { - setMessage(null); - const enabled = themes.filter(t => t.enabled); - if (enabled.length === 0) { - setMessage({ type: 'success', text: 'All themes are already disabled' }); - return; - } - let failed = 0; - for (const t of enabled) { - const res = await apiFetch('/api/admin/themes', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: t.id, enabled: false }), - }); - if (!res.ok) failed++; - } - if (failed === 0) { - await fetchThemes(); - setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` }); - } else { - await fetchThemes(); - setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` }); - } - } - - async function deleteTheme(id: string, name: string) { - if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return; - - setMessage(null); - const res = await apiFetch('/api/admin/themes', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - - if (res.ok) { - setThemes(prev => prev.filter(t => t.id !== id)); - setMessage({ type: 'success', text: `Theme "${name}" removed` }); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Delete failed' }); - } - } - - if (loading) { - return
Loading...
; - } - - const themesEnabled = policy.features.themesEnabled ?? true; - const userThemesEnabled = policy.features.userThemesEnabled ?? true; - - return ( -
-
-
-

Themes

-

Manage themes and theme policy for all users

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

Theme Policy

-
-

Control theme availability and defaults for users

-
- -
-
-
- Themes Enabled -

Allow users to select and apply themes

-
- -
- -
-
- User Theme Uploads -

Allow users to upload their own theme files

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

Bulk toggle all deployed themes at once

-
-
- - -
-
- )} - -
-
-
- Default Theme -

Theme applied when users have not chosen one

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

Deployed Themes

-
-

Admin-uploaded themes available to all users

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

No themes installed

-

Upload a theme ZIP file to get started

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

{theme.description}

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

Version

-

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

-
- -
- - {checkResult && ( -
- {checkResult.msg} -
- )} - - - - - - {chip.label} - - - - {data.current} - - {newer && ( - - {releaseUrl ? ( - - {newer} - - ) : ( - {newer} - )} - - )} - {status?.advisory && ( - - {status.advisory} - - )} - - - - - {timeAgo(data.lastCheckedAt)} - - - {timeAgo(data.lastSuccessAt)} - - - {timeAgo(data.nextScheduledAt)} - - {status?.checkedAt && ( - - {new Date(status.checkedAt).toLocaleString()} - - )} - - - - - - {data.endpoint} - - - - - {data.disabledByEnv ? 'Yes' : 'No'} - - - -
- ); -} diff --git a/app/(main)/admin/_unsaved-changes-guard.tsx b/app/(main)/admin/_unsaved-changes-guard.tsx new file mode 100644 index 000000000..ed07cfa58 --- /dev/null +++ b/app/(main)/admin/_unsaved-changes-guard.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useId } from 'react'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle } from '@/components/icons'; +import { useFocusTrap } from '@/hooks/use-focus-trap'; +import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store'; + +/** + * Asks before the admin leaves a section with unsaved edits (#1255). Tab + * buttons and sign out go through requestLeave themselves; links anywhere on + * the page are caught here, and closing or reloading the tab gets the + * browser's own prompt. + */ +export function UnsavedChangesGuard() { + const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved); + const pendingLeave = useAdminUnsavedStore((s) => s.pendingLeave); + const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); + const confirmLeave = useAdminUnsavedStore((s) => s.confirmLeave); + const cancelLeave = useAdminUnsavedStore((s) => s.cancelLeave); + const isOpen = pendingLeave !== null; + const id = useId(); + + const dialogRef = useFocusTrap({ isActive: isOpen, restoreFocus: true }); + + // On document, not the dialog, so Escape means "Stay" wherever focus is. + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + cancelLeave(); + }; + document.addEventListener('keydown', onKeyDown, true); + return () => document.removeEventListener('keydown', onKeyDown, true); + }, [isOpen, cancelLeave]); + + useEffect(() => { + if (!hasUnsaved) return; + const onBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); + }, [hasUnsaved]); + + useEffect(() => { + if (!hasUnsaved) return; + // Capture phase on document runs before React's root listener, so a + // Next never starts its client-side navigation. + const onClick = (e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0) return; + // Read the store, not the render: after "Leave" the click is replayed + // before React has removed this listener. + if (!selectHasUnsaved(useAdminUnsavedStore.getState())) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const anchor = (e.target as Element | null)?.closest?.('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return; + if (anchor.target && anchor.target !== '_self') return; + if (anchor.hasAttribute('download')) return; + const url = new URL(anchor.href, window.location.href); + if (url.origin === window.location.origin + && url.pathname === window.location.pathname + && url.search === window.location.search) return; + e.preventDefault(); + e.stopPropagation(); + requestLeave(() => { + // Nothing is dirty any more, so the second click goes through. + if (anchor.isConnected) anchor.click(); + else window.location.assign(anchor.href); + }); + }; + document.addEventListener('click', onClick, true); + return () => document.removeEventListener('click', onClick, true); + }, [hasUnsaved, requestLeave]); + + if (!isOpen) return null; + + return ( +
{ if (e.target === e.currentTarget) cancelLeave(); }} + > +
+
+
+ +
+
+

+ Unsaved changes +

+

+ You have changes that haven't been saved. If you leave now, they will be lost. +

+
+
+
+ + +
+
+
+ ); +} diff --git a/app/(main)/admin/auth/page.tsx b/app/(main)/admin/auth/page.tsx deleted file mode 100644 index a637439af..000000000 --- a/app/(main)/admin/auth/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=auth'); -} diff --git a/app/(main)/admin/branding/page.tsx b/app/(main)/admin/branding/page.tsx deleted file mode 100644 index cc6f73b7d..000000000 --- a/app/(main)/admin/branding/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=branding'); -} diff --git a/app/(main)/admin/change-password/page.tsx b/app/(main)/admin/change-password/page.tsx index a58206b1f..2881c69cf 100644 --- a/app/(main)/admin/change-password/page.tsx +++ b/app/(main)/admin/change-password/page.tsx @@ -2,29 +2,32 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Lock } from '@/components/icons'; +import { Loader2 } from '@/components/icons'; import { apiFetch } from '@/lib/browser-navigation'; +import { Button } from '@/components/ui/button'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { Flash, useFlash } from '@/components/admin/flash-message'; +import { inputClass } from '@/components/admin/config-items'; +import { cn } from '@/lib/utils'; export default function ChangePasswordPage() { const router = useRouter(); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); + const [message, setMessage] = useFlash(); const [loading, setLoading] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - setError(''); - setSuccess(false); + setMessage(null); if (newPassword.length < 8) { - setError('New password must be at least 8 characters.'); + setMessage({ type: 'error', text: 'The new password must be at least 8 characters.' }); return; } if (newPassword !== confirmPassword) { - setError('New passwords do not match.'); + setMessage({ type: 'error', text: 'The new passwords do not match.' }); return; } @@ -36,81 +39,51 @@ export default function ChangePasswordPage() { }); if (res.ok) { - setSuccess(true); + setMessage({ type: 'success', text: 'Password changed. Taking you back to the overview.' }); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setTimeout(() => router.push('/admin'), 2000); } else { const data = await res.json().catch(() => ({})); - setError(data.error || 'Failed to change password.'); + setMessage({ type: 'error', text: data.error || 'Could not change the password.' }); } setLoading(false); } + const fields = [ + { id: 'current', label: 'Current password', value: currentPassword, set: setCurrentPassword, autoComplete: 'current-password' }, + { id: 'new', label: 'New password', value: newPassword, set: setNewPassword, autoComplete: 'new-password' }, + { id: 'confirm', label: 'Confirm new password', value: confirmPassword, set: setConfirmPassword, autoComplete: 'new-password' }, + ] as const; + return ( -
-
-

Change Password

-

Update your admin password.

-
+
+ + + setMessage(null)} />
-
- -
- + {fields.map((field) => ( +
+ 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')} />
-
+ ))} -
- - 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" - /> -
- -
- - 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" - /> -
- - {error && ( -

{error}

- )} - {success && ( -

Password changed. Redirecting...

- )} - - +
); diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index 9507b774f..c666fd358 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -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/ 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(null); - const [authError, setAuthError] = useState(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 tags (hard navigation). Next.js only auto-applies - // basePath to /router APIs - for these we prepend it manually so - // NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/". - const prefix = getPathPrefix(); - - const navContent = ( - <> -
-
- {NAV_GROUPS.map((group, groupIndex) => ( -
- {groupIndex > 0 &&
} -
- - {group.label} - -
- {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 ( - - ); - })} -
- ))} -
-
- -
- {!isStalwartAutoLogin && ( - - - Change Password - - )} - -
- - ); - return ( -
- {/* Slim webmail nav rail (desktop only) */} - - - {/* Admin Sidebar (desktop only) */} - - - {/* Mobile drawer overlay */} - {mobileNavOpen && ( -
setMobileNavOpen(false)} - aria-hidden="true" - /> - )} - - {/* Mobile drawer */} - - - {/* Main content */} -
- {/* Mobile header */} -
- -
- {logoUrl ? ( - - ) : ( - - )} - Admin Panel -
-
- -
- {authError ? ( -
-

Admin authentication failed

-

{authError}

-
- ) : authenticated === null ? ( -
- Loading admin panel… -
- ) : authenticated ? ( - children - ) : null} -
-
- - {/* Mobile bottom nav (main webmail nav) */} - -
+ + + + {children} + + + ); } diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx index 3e870475b..2259f9248 100644 --- a/app/(main)/admin/login/page.tsx +++ b/app/(main)/admin/login/page.tsx @@ -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 { diff --git a/app/(main)/admin/logs/page.tsx b/app/(main)/admin/logs/page.tsx deleted file mode 100644 index 669b675a7..000000000 --- a/app/(main)/admin/logs/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=logs'); -} diff --git a/app/(main)/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx deleted file mode 100644 index e8c34b9c7..000000000 --- a/app/(main)/admin/marketplace/[slug]/page.tsx +++ /dev/null @@ -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 | null; - }>; - createdAt: string | null; - updatedAt: string | null; - }; - bundle: { - manifest: Record | 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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
- - Loading... -
- ); - } - - if (error || !data) { - return ( -
- - Back to Marketplace - -

{error || 'Extension not found'}

-
- ); - } - - 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 | 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 ( -
- {/* Back link */} - - Back to Marketplace - - - {/* Banner / hero */} - {ext.bannerUrl && ( -
- -
- )} - - {/* Header */} -
-
-
- {ext.iconUrl ? ( - - ) : isPlugin ? ( - - ) : ( - - )} -
-
-
-

{ext.name}

- {ext.featured && } - {data.installed && !updateAvailable && ( - - Installed - - )} - {data.installed && updateAvailable && ( - - Update available - - )} -
-
- - {isPlugin ? (ext.pluginType || 'plugin') : 'theme'} - - {ext.author && ( - by {ext.author.displayName} - )} - {ext.latestVersion && v{ext.latestVersion}} - {ext.license && {ext.license}} - - - {ext.totalDownloads.toLocaleString()} - -
-
-
- - {/* Action buttons */} -
- {data.installed ? ( - <> - {updateAvailable && ( - - )} - - - Manage - - - - ) : ( - - )} -
-
- - {message && ( -
- {message.text} -
- )} - - {versionMismatch && ( -
- -
-

Update Bulwark to install this extension

-

- Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}. -

-
-
- )} - - {bundle.error && ( -
- -
-

Could not preview bundle

-

{bundle.error}

-
-
- )} - - {/* Description */} -
-

About

-

{ext.description}

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

{ext.longDescription}

- )} - {ext.tags.length > 0 && ( -
- {ext.tags.map(tag => ( - - {tag} - - ))} -
- )} -
- {ext.minAppVersion && Requires app v{ext.minAppVersion}+} - {bundle.size > 0 && Bundle: {(bundle.size / 1024).toFixed(1)} KB} - {ext.githubRepo && ( - - - {ext.githubRepo} - - )} -
-
- - {/* Screenshots */} - {ext.screenshots.length > 0 && ( -
-

Screenshots

-
- {ext.screenshots.map((s, i) => ( - {s.altText - ))} -
-
- )} - - {/* Theme color preview */} - {!isPlugin && ext.themePreviews.length > 0 && ( -
-

Theme preview

-
- {ext.themePreviews.map(preview => ( - - ))} -
-
- )} - - {/* Permissions */} - {isPlugin && ( -
-
- -

Permissions

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

This plugin requests no permissions.

- ) : ( -
    - {manifestPerms.map(perm => { - const risky = RISKY_PERMISSIONS.has(perm); - return ( -
  • - {risky && } - {perm} -
  • - ); - })} -
- )} - {frameOrigins.length > 0 && ( -
-

Iframe origins

-

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

-
    - {frameOrigins.map(origin => ( -
  • - {origin} -
  • - ))} -
-
- )} -
- )} - - {/* Settings schema preview */} - {isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && ( -
-

User settings

-

Settings users will be able to configure after install.

-
    - {Object.entries(settingsSchema).map(([key, field]) => ( -
  • -
    - {key} - {field.type} -
    -
    {field.label}
    - {field.description && ( -
    {field.description}
    - )} -
  • - ))} -
-
- )} - - {/* Source / manifest disclosure */} - {bundle.manifest && ( -
- - {showManifest && ( -
-              {JSON.stringify(bundle.manifest, null, 2)}
-            
- )} -
- )} - - {bundle.source && ( -
- - {showSource && ( -
-              {bundle.source.content}
-            
- )} -
- )} - - {/* Version history */} - {ext.versions.length > 0 && ( -
-

Version history

-
    - {ext.versions.slice(0, 5).map(v => ( -
  • -
    -
    - v{v.version} - {v.publishedAt && ( - - {new Date(v.publishedAt).toLocaleDateString()} - - )} -
    - {v.changelog && ( -

    {v.changelog}

    - )} -
    - - {(v.bundleSize / 1024).toFixed(1)} KB - -
  • - ))} -
-
- )} -
- ); -} - -function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record | null } }) { - const colors = preview.colors || {}; - const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff'); - const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a'); - const accent = colors.primary || colors.accent || '#7c5cff'; - const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5'); - const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5'); - - return ( -
-
- {preview.variant} -
-
-
- - Sample text -
-
- Card surface -
-
- {Object.entries(colors).slice(0, 6).map(([key, value]) => ( - - ))} -
-
-
- ); -} diff --git a/app/(main)/admin/marketplace/page.tsx b/app/(main)/admin/marketplace/page.tsx deleted file mode 100644 index ac089504e..000000000 --- a/app/(main)/admin/marketplace/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=marketplace'); -} diff --git a/app/(main)/admin/page.tsx b/app/(main)/admin/page.tsx deleted file mode 100644 index 71562e547..000000000 --- a/app/(main)/admin/page.tsx +++ /dev/null @@ -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//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 ; - case 'settings': return ; - case 'branding': return ; - case 'auth': return ; - case 'policy': return ; - case 'plugins': return ; - case 'themes': return ; - case 'marketplace': return ; - case 'version': return ; - case 'telemetry': return ; - case 'logs': return ; - } -} diff --git a/app/(main)/admin/plugins/[id]/page.tsx b/app/(main)/admin/plugins/[id]/page.tsx deleted file mode 100644 index dd7ae43dc..000000000 --- a/app/(main)/admin/plugins/[id]/page.tsx +++ /dev/null @@ -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'); -} diff --git a/app/(main)/admin/plugins/page.tsx b/app/(main)/admin/plugins/page.tsx deleted file mode 100644 index 804485a2c..000000000 --- a/app/(main)/admin/plugins/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=plugins'); -} diff --git a/app/(main)/admin/policy/page.tsx b/app/(main)/admin/policy/page.tsx deleted file mode 100644 index 9d33304ca..000000000 --- a/app/(main)/admin/policy/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=policy'); -} diff --git a/app/(main)/admin/settings/page.tsx b/app/(main)/admin/settings/page.tsx deleted file mode 100644 index 7a6e10202..000000000 --- a/app/(main)/admin/settings/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=settings'); -} diff --git a/app/(main)/admin/telemetry/page.tsx b/app/(main)/admin/telemetry/page.tsx deleted file mode 100644 index 73343a1db..000000000 --- a/app/(main)/admin/telemetry/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=telemetry'); -} diff --git a/app/(main)/admin/themes/page.tsx b/app/(main)/admin/themes/page.tsx deleted file mode 100644 index 3dc03d45e..000000000 --- a/app/(main)/admin/themes/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=themes'); -} diff --git a/app/(main)/admin/version/page.tsx b/app/(main)/admin/version/page.tsx deleted file mode 100644 index 893b1dfe7..000000000 --- a/app/(main)/admin/version/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function Page() { - redirect('/admin?tab=version'); -} diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx deleted file mode 100644 index 032544b81..000000000 --- a/app/(sandbox)/layout.tsx +++ /dev/null @@ -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 ( - - - {/* 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' && ( - - )} - {children} - - - ); -} diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css index 6265d9eae..ff06f66b4 100644 --- a/app/(sandbox)/plugin-kit.css +++ b/app/(sandbox)/plugin-kit.css @@ -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. diff --git a/app/(sandbox)/plugin-sandbox-privileged/page.tsx b/app/(sandbox)/plugin-sandbox-privileged/page.tsx deleted file mode 100644 index 0f3d431d3..000000000 --- a/app/(sandbox)/plugin-sandbox-privileged/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/(sandbox)/plugin-sandbox-privileged/route.ts b/app/(sandbox)/plugin-sandbox-privileged/route.ts new file mode 100644 index 000000000..5ed0a733b --- /dev/null +++ b/app/(sandbox)/plugin-sandbox-privileged/route.ts @@ -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 { + return sandboxDocument(request); +} diff --git a/app/(sandbox)/plugin-sandbox/page.tsx b/app/(sandbox)/plugin-sandbox/page.tsx deleted file mode 100644 index fa9576eed..000000000 --- a/app/(sandbox)/plugin-sandbox/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/app/(sandbox)/plugin-sandbox/route.ts b/app/(sandbox)/plugin-sandbox/route.ts new file mode 100644 index 000000000..c02b187cd --- /dev/null +++ b/app/(sandbox)/plugin-sandbox/route.ts @@ -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 { + return sandboxDocument(request); +} diff --git a/app/api/admin/__tests__/config-secrets.test.ts b/app/api/admin/__tests__/config-secrets.test.ts new file mode 100644 index 000000000..502eccf28 --- /dev/null +++ b/app/api/admin/__tests__/config-secrets.test.ts @@ -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 { + 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'); + }); +}); diff --git a/app/api/admin/__tests__/marketplace-install-guards.test.ts b/app/api/admin/__tests__/marketplace-install-guards.test.ts new file mode 100644 index 000000000..ebfa636b9 --- /dev/null +++ b/app/api/admin/__tests__/marketplace-install-guards.test.ts @@ -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 = {}; +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); + }); +}); diff --git a/app/api/admin/__tests__/read-only-config.test.ts b/app/api/admin/__tests__/read-only-config.test.ts new file mode 100644 index 000000000..f7e7a429c --- /dev/null +++ b/app/api/admin/__tests__/read-only-config.test.ts @@ -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(); + 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'); + }); +}); diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 11f68b1e6..b7d5e4ff5 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -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 }); } diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts index e29cc542c..2cc06b3db 100644 --- a/app/api/admin/change-password/route.ts +++ b/app/api/admin/change-password/route.ts @@ -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 }); } diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index c65dcb10a..2981b5b8a 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -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('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 }); } diff --git a/app/api/admin/extensions/[kind]/[id]/history/route.ts b/app/api/admin/extensions/[kind]/[id]/history/route.ts new file mode 100644 index 000000000..495513a86 --- /dev/null +++ b/app/api/admin/extensions/[kind]/[id]/history/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/extensions/[kind]/[id]/rollback/route.ts b/app/api/admin/extensions/[kind]/[id]/rollback/route.ts new file mode 100644 index 000000000..651eefcb5 --- /dev/null +++ b/app/api/admin/extensions/[kind]/[id]/rollback/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/extensions/__tests__/rollback.test.ts b/app/api/admin/extensions/__tests__/rollback.test.ts new file mode 100644 index 000000000..bc7857535 --- /dev/null +++ b/app/api/admin/extensions/__tests__/rollback.test.ts @@ -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]> = []; +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async (a: string, d: Record) => { audit.push([a, d]); }) })); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + 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}'); + }); +}); diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index 8a0b75571..f2df05676 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -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 { - await configManager.ensureLoaded(); - return configManager.get('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 | 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 | 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('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>).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' } }, ); diff --git a/app/api/admin/marketplace/__tests__/preview.test.ts b/app/api/admin/marketplace/__tests__/preview.test.ts new file mode 100644 index 000000000..ca4d8897b --- /dev/null +++ b/app/api/admin/marketplace/__tests__/preview.test.ts @@ -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 = {}) => ({ + 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); + }); +}); diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts index 747e39e3c..0bfb73111 100644 --- a/app/api/admin/marketplace/__tests__/route.test.ts +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -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, 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 = {}, 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) { 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' }); diff --git a/app/api/admin/marketplace/__tests__/signature.test.ts b/app/api/admin/marketplace/__tests__/signature.test.ts index 9d588359b..7548deb2c 100644 --- a/app/api/admin/marketplace/__tests__/signature.test.ts +++ b/app/api/admin/marketplace/__tests__/signature.test.ts @@ -70,7 +70,13 @@ function serve(bytes: Uint8Array, headers: Record) { 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 }); })); } diff --git a/app/api/admin/marketplace/__tests__/updates.test.ts b/app/api/admin/marketplace/__tests__/updates.test.ts new file mode 100644 index 000000000..000c525cd --- /dev/null +++ b/app/api/admin/marketplace/__tests__/updates.test.ts @@ -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); + }); +}); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 9cf833457..4868a19ee 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -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 { - await configManager.ensureLoaded(); - return configManager.get('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('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 = {}; + 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) => { - 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 }); } diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts new file mode 100644 index 000000000..52b14e8cd --- /dev/null +++ b/app/api/admin/marketplace/updates/route.ts @@ -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; + 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 }); + } +} diff --git a/app/api/admin/oauth/setup/__tests__/route.test.ts b/app/api/admin/oauth/setup/__tests__/route.test.ts new file mode 100644 index 000000000..f5939c382 --- /dev/null +++ b/app/api/admin/oauth/setup/__tests__/route.test.ts @@ -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]> = []; + +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()), + 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'] }]); + }); +}); diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts index 0bc03fb31..9eaff08f4 100644 --- a/app/api/admin/oauth/setup/route.ts +++ b/app/api/admin/oauth/setup/route.ts @@ -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; notCreated?: Record; notUpdated?: Record; }; @@ -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, diff --git a/app/api/admin/plugin-approvals/route.ts b/app/api/admin/plugin-approvals/route.ts index 200f3ad78..684f3e706 100644 --- a/app/api/admin/plugin-approvals/route.ts +++ b/app/api/admin/plugin-approvals/route.ts @@ -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 }); } diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index 7e75f1e61..b6b8a61a4 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -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 }); } } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 7e0306515..9eb98fa7e 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -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 }); } diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 56b3b8910..6e707577e 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -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 }); } diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 3950021aa..f9840a5ce 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -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 }); } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 276b09afe..3b0bbb5dd 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -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); diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index cc487e8c4..288603a92 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -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 }); } diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 01a40a673..dfab68351 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -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, diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 963179dc8..0631a5399 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -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, diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 0b6416c0c..eb1cb72c5 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -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 { 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 }); diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index 780ad690b..59efca39b 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -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) { diff --git a/app/api/caldav/discover/route.ts b/app/api/caldav/discover/route.ts index cd425ac1a..57fc62894 100644 --- a/app/api/caldav/discover/route.ts +++ b/app/api/caldav/discover/route.ts @@ -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 = {}; @@ -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; diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts index 87de75fb9..4ff6fd1a1 100644 --- a/app/api/connector/capabilities/route.ts +++ b/app/api/connector/capabilities/route.ts @@ -15,7 +15,8 @@ import { TARGET_NAMES } from '@/lib/connector/registry'; * here, and the answer is narrower than the login page it sits next to. It * says nothing about accounts, domains or the mail server - only the product, * its version, the branded app name, and the target names, which are a - * compile-time constant. + * compile-time constant. The version follows loginShowVersion, like the login + * page (#896); the connector only needs the target names to warn. * * `appName` can be a customer's own branding, so the whole endpoint is gated * on `connectorEnabled` for deployments that would rather disclose nothing. @@ -36,7 +37,9 @@ export async function GET() { product: 'bulwark-webmail', appName: configManager.get('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Bulwark', - version: process.env.NEXT_PUBLIC_APP_VERSION || null, + version: configManager.get('loginShowVersion', true) + ? process.env.NEXT_PUBLIC_APP_VERSION || null + : null, connectorPath: '/connector', targets: TARGET_NAMES, }, diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 41d292c1e..7dc48fab8 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -3,6 +3,9 @@ import { NextRequest, NextResponse } from 'next/server'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks +/** Concurrent upstream icon fetches across all callers (see GET). */ +const MAX_UPSTREAM_IN_FLIGHT = 16; +let upstreamInFlight = 0; interface CacheEntry { data: ArrayBuffer; @@ -18,6 +21,9 @@ const cache = new Map(); const negativeCache = new Map(); const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day const NEGATIVE_CACHE_MAX_SIZE = 2000; +// A refresh request (?v=) refetches a domain at most this often, so the +// unauthenticated route can't be used to hammer the upstream icon service. +const MIN_REFRESH_INTERVAL_MS = 60 * 1000; // 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no // favicon exists for a domain, so the browser's tag loads it cleanly @@ -444,21 +450,30 @@ export async function GET(request: NextRequest) { // Resolve to root domain so subdomains share the same favicon lookup const normalizedDomain = getRootDomain(domain.toLowerCase()); + const refreshAfter = parseRefreshAfter(request.nextUrl.searchParams.get('v')); // Check negative cache (domains known to have no favicon) const neg = negativeCache.get(normalizedDomain); - if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) { + if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS && !isStale(neg.fetchedAt, refreshAfter)) { return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS }); } // Check cache const cached = cache.get(normalizedDomain); - if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS && !isStale(cached.fetchedAt, refreshAfter)) { return new NextResponse(cached.data, { headers: faviconHeaders(cached.contentType), }); } + // Unauthenticated and keyed by domain, so without a ceiling on concurrent + // upstream fetches any visitor could have this instance open one + // connection per made-up domain name. Over the ceiling the icon is simply + // missing for now; the browser asks again later. + if (upstreamInFlight >= MAX_UPSTREAM_IN_FLIGHT) { + return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS }); + } + upstreamInFlight++; try { const upstream = await fetch( `https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`, @@ -490,6 +505,7 @@ export async function GET(request: NextRequest) { } // Cache the result + negativeCache.delete(normalizedDomain); evictOldest(); cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() }); @@ -497,13 +513,35 @@ export async function GET(request: NextRequest) { headers: faviconHeaders(contentType), }); } catch { + // A refresh that can't reach upstream keeps showing the icon we have. + if (cached) { + return new NextResponse(cached.data, { headers: faviconHeaders(cached.contentType) }); + } return new NextResponse(null, { status: 502, headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min }); + } finally { + upstreamInFlight--; } } +/** + * The time from a ?v= refresh stamp, or null. Avatars send the moment the + * user asked for fresh sender icons (#960); a stamp in the future is clamped + * to now so it can't keep a domain permanently uncached. + */ +function parseRefreshAfter(value: string | null): number | null { + if (!value || !/^\d{1,15}$/.test(value)) return null; + return Math.min(Number(value), Date.now()); +} + +/** Whether a cache entry predates the refresh the client asked for. */ +function isStale(fetchedAt: number, refreshAfter: number | null): boolean { + if (refreshAfter === null || fetchedAt >= refreshAfter) return false; + return Date.now() - fetchedAt >= MIN_REFRESH_INTERVAL_MS; +} + const RASTER_FAVICON_TYPES = new Set([ 'image/x-icon', 'image/vnd.microsoft.icon', diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index 244ad5d0e..d453d9894 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -19,6 +19,44 @@ function getFetchTimeoutMs(maxBytes: number): number { return Math.max(BASE_TIMEOUT_MS, Math.ceil(BASE_TIMEOUT_MS * (maxBytes / DEFAULT_MAX_BYTES))); } +/** + * The body as bytes, or null once it exceeds `maxBytes`, in which case the + * upstream connection is aborted rather than drained. + */ +async function readBodyCapped( + response: PublicFetchResponse | Response, + maxBytes: number, + controller: AbortController, +): Promise { + if (!response.body) { + const buffer = await response.arrayBuffer(); + return buffer.byteLength > maxBytes ? null : buffer; + } + const reader = (response.body as ReadableStream).getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const chunk = value as Uint8Array; + received += chunk.byteLength; + if (received > maxBytes) { + controller.abort(); + reader.cancel().catch(() => {}); + return null; + } + chunks.push(chunk); + } + const out = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out.buffer; +} + function tooLarge(maxBytes: number) { const limitMb = Math.round((maxBytes / (1024 * 1024)) * 10) / 10; return NextResponse.json( @@ -144,8 +182,12 @@ export async function POST(request: NextRequest) { return tooLarge(maxResponseSize); } - const buffer = await response.arrayBuffer(); - if (buffer.byteLength > maxResponseSize) { + // Read up to the cap and no further: a chunked body with no + // Content-Length used to be buffered whole before it was measured, so a + // feed that streams for the whole timeout could take the process's + // memory with it. + const buffer = await readBodyCapped(response, maxResponseSize, controller); + if (!buffer) { return tooLarge(maxResponseSize); } diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts index d33f659c6..8d8374651 100644 --- a/app/api/plugin-approval-status/route.ts +++ b/app/api/plugin-approval-status/route.ts @@ -107,6 +107,7 @@ export async function POST(request: NextRequest) { permissions: manifest.permissions, httpOrigins: manifest.httpOrigins, apiPostPaths: manifest.apiPostPaths, + ...(manifest.credentials ? { credentials: manifest.credentials } : {}), }; let entry: ApprovalEntry; diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts index c99c310c9..81ecbcc6b 100644 --- a/app/api/plugin-secrets/__tests__/route.test.ts +++ b/app/api/plugin-secrets/__tests__/route.test.ts @@ -26,6 +26,14 @@ vi.mock('@/lib/plugin-secrets/vault', () => ({ listUserCredentials: vi.fn(async () => []), deleteUserCredentials: vi.fn(async () => {}), })); +// What the server knows the plugins declare (lib/plugin-secrets/known-plugins). +const declared: Record = { + 'nextcloud/cloud': { scope: 'user', label: 'Nextcloud', inject: { type: 'basic' } }, + 'paperless/docs': { scope: 'user', label: 'Paperless', inject: { type: 'bearer' }, origin: 'https://paperless.example.com' }, +}; +vi.mock('@/lib/plugin-secrets/known-plugins', () => ({ + declaredUserCredential: async (pluginId: string, credentialId: string) => declared[`${pluginId}/${credentialId}`] ?? null, +})); const proxied: unknown[] = []; vi.mock('@/lib/plugin-secrets/proxy', async (orig) => { const real = await orig(); @@ -90,6 +98,20 @@ describe('POST /api/plugin-secrets', () => { expect(store.size).toBe(0); }); + it('refuses a credential no known plugin declares, so the proxy is no relay for made-up plugin ids', async () => { + expect((await save({ ...VALID, pluginId: 'made-up' })).status).toBe(404); + expect((await save({ ...VALID, credentialId: 'other' })).status).toBe(404); + expect(store.size).toBe(0); + }); + + it('keeps a credential on the server its plugin fixes and stores the inject the manifest declares', async () => { + const paperless = { ...VALID, pluginId: 'paperless', credentialId: 'docs', inject: { type: 'basic' } }; + expect((await save({ ...paperless, origin: 'https://elsewhere.example.com' })).status).toBe(400); + expect(store.size).toBe(0); + expect((await save({ ...paperless, origin: 'https://paperless.example.com/api/' })).status).toBe(200); + expect(store.get('paperless/docs')).toMatchObject({ origin: 'https://paperless.example.com', inject: { type: 'bearer' } }); + }); + it('refuses without a session, with plugins off, or from another site', async () => { signedIn = false; expect((await save(VALID)).status).toBe(401); diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index 0e67b59ff..e56909a4e 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -4,6 +4,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials'; import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy'; +import { declaredUserCredential } from '@/lib/plugin-secrets/known-plugins'; import { VaultFullError, deleteUserCredentials, @@ -76,9 +77,17 @@ export async function POST(request: NextRequest) { if (username !== undefined && (typeof username !== 'string' || username.length > 256 || /[\r\n]/.test(username))) return bad('Invalid username'); if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || /[\r\n]/.test(secret))) return bad('Invalid secret'); + // A credential belongs to a plugin this server knows declares it: without + // that, any signed-in user could park a secret under a made-up plugin id + // and use the proxy as a relay to any server. The manifest's own inject + // and, when fixed, its origin are what count, not what the browser sent. + const spec = await declaredUserCredential(pluginId, credentialId); + if (!spec) return NextResponse.json({ error: 'No such plugin credential' }, { status: 404 }); + if (spec.origin && credentialOrigin(spec.origin) !== origin) return bad('This credential is only for the server its plugin names'); + try { await putUserCredential(user, pluginId, credentialId, { - origin, inject, label: label.trim(), + origin, inject: spec.inject, label: label.trim(), ...(typeof username === 'string' && username ? { username } : {}), ...(typeof secret === 'string' ? { secret } : {}), }); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 8b198bf5a..8772f4fc3 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -12,6 +12,9 @@ import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { mergeStoredTemplates } from '@/lib/template-merge'; import { mergeStoredSubscriptions } from '@/lib/calendar-subscription-sync'; +/** Largest settings blob a client may store (templates and signatures included). */ +const SETTINGS_MAX_BYTES = 2 * 1024 * 1024; + function classifyError(error: unknown): { message: string; status: number } { const code = (error as NodeJS.ErrnoException).code; const msg = error instanceof Error ? error.message : 'Unknown error'; @@ -79,20 +82,25 @@ async function verifyIdentity(username: string, serverUrl: string): Promise SETTINGS_MAX_BYTES) { + return NextResponse.json({ error: 'Settings are too large' }, { status: 413 }); + } + const text = await request.text(); + if (text.length > SETTINGS_MAX_BYTES) { + return NextResponse.json({ error: 'Settings are too large' }, { status: 413 }); + } + const { username, serverUrl, settings } = JSON.parse(text); if (!username || !serverUrl || !settings) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } diff --git a/app/globals.css b/app/globals.css index 3ed4e0f32..3e369a320 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,6 +3,23 @@ @import "./theme-inline.css"; +/* The app's font stack replaces Tailwind's default --font-sans, so the + font-sans utility and body use the same stack, and a theme's + typography.fontSans (compiled to --font-sans on :root) restyles both. */ +@theme { + --font-sans: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans Thai", + "Leelawadee UI", + Tahoma, + sans-serif; +} + :root { font-size: var(--font-size-base); @@ -142,17 +159,7 @@ body { background-color: var(--color-background); color: var(--color-foreground); - font-family: - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - "Helvetica Neue", - Arial, - "Noto Sans Thai", - "Leelawadee UI", - Tahoma, - sans-serif; + font-family: var(--font-sans); font-feature-settings: "rlig" 1, "calt" 1; @@ -810,6 +817,45 @@ optgroup { cursor: pointer; } +/* @mention chip (mention-chip.tsx). In the sent mail it is a plain mailto link. */ +.tiptap a.mention { + display: inline-block; + padding: 0 0.3em; + border-radius: 0.375rem; + background-color: color-mix(in srgb, var(--color-primary) 14%, transparent); + color: var(--color-primary); + font-weight: 500; + line-height: 1.35; + text-decoration: none; + cursor: pointer; + user-select: none; + transition: background-color 120ms, box-shadow 120ms; +} + +/* Hover says it opens something on click; the card itself waits for the click. */ +.tiptap a.mention:hover { + background-color: color-mix(in srgb, var(--color-primary) 24%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 45%, transparent); +} + +.tiptap a.mention.mention-outside:hover { + background-color: color-mix(in srgb, currentColor 10%, transparent); + box-shadow: inset 0 0 0 1px currentColor; +} + +.tiptap a.mention.ProseMirror-selectednode { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + +/* Named in the text but not in To or Cc. */ +.tiptap a.mention.mention-outside { + background-color: transparent; + color: var(--color-muted-foreground); + box-shadow: inset 0 0 0 1px currentColor; + border: 0; +} + .tiptap img { max-width: 100%; height: auto; diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx index f82907cbe..833fc40ab 100644 --- a/components/__tests__/push-notification-prompt.test.tsx +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ enableWebPush: vi.fn(), isWebPushEnabled: vi.fn(), resyncWebPush: vi.fn(), + waitForWebPushResync: vi.fn(), authState: { client: { getAccountId: () => "account-1" } as { getAccountId: () => string; @@ -75,6 +76,7 @@ vi.mock("@/lib/web-push", () => ({ isWebPushSupported: () => mocks.supported, isWebPushEnabled: mocks.isWebPushEnabled, resyncWebPush: mocks.resyncWebPush, + waitForWebPushResync: mocks.waitForWebPushResync, })); function setNotificationPermission(permission: NotificationPermission) { @@ -119,12 +121,14 @@ describe("PushNotificationPrompt", () => { mocks.enableWebPush.mockReset().mockResolvedValue({ subscriptionId: "sub-1" }); mocks.isWebPushEnabled.mockReset().mockImplementation(async () => mocks.enabled); mocks.resyncWebPush.mockReset().mockResolvedValue(false); + mocks.waitForWebPushResync.mockReset().mockResolvedValue(undefined); setNotificationPermission("default"); }); afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); + Reflect.deleteProperty(navigator, "serviceWorker"); }); it("offers onboarding when the current account has no push subscription", async () => { @@ -226,6 +230,68 @@ describe("PushNotificationPrompt", () => { expect(screen.queryByText("title")).not.toBeInTheDocument(); }); + it("waits for the background pass before deciding on onboarding (#1321)", async () => { + // Safari dropped the browser subscription with the site data; the saved + // opt-in survived, so the pass on load is recreating it right now. + let restored!: () => void; + const restoring = new Promise((resolve) => { restored = resolve; }); + mocks.resyncWebPush.mockReturnValue(restoring.then(() => true)); + mocks.waitForWebPushResync.mockReturnValue(restoring); + + render(); + await advancePromptDelay(); + expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + + // The subscription is back: nothing to ask the user. + mocks.enabled = true; + await act(async () => { + restored(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1"); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("offers onboarding when push is still off after the background pass", async () => { + let settled!: () => void; + const pass = new Promise((resolve) => { settled = resolve; }); + mocks.resyncWebPush.mockReturnValue(pass.then(() => false)); + mocks.waitForWebPushResync.mockReturnValue(pass); + + render(); + await advancePromptDelay(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + + await act(async () => { + settled(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText("title")).toBeInTheDocument(); + }); + + it("re-registers every login when the service worker reports a replaced subscription", async () => { + const serviceWorker = new EventTarget(); + Object.defineProperty(navigator, "serviceWorker", { configurable: true, value: serviceWorker }); + const bob = { getAccountId: () => "account-2" }; + mocks.authState.connected.set("bob@mail.example.com", bob); + render(); + await advancePromptDelay(); + mocks.resyncWebPush.mockClear(); + + await act(async () => { + serviceWorker.dispatchEvent( + new MessageEvent("message", { data: { kind: "pushsubscriptionchange" } }), + ); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mocks.resyncWebPush.mock.calls.map(([p]) => [p.client, p.force])).toEqual([ + [mocks.authState.client, true], + [bob, true], + ]); + }); + it("re-syncs an existing registration in the background on mount", async () => { mocks.enabled = true; render(); diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx new file mode 100644 index 000000000..bc65878bf --- /dev/null +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('next/navigation', () => ({ usePathname: () => '/admin' })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/settings/about-data-settings', () => ({ AboutFooter: () => null })); +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(() => Promise.resolve({ ok: false })), + getPathPrefix: () => '', + withBasePath: (href: string) => href, +})); + +import { AdminShell } from '../admin-shell'; + +function setDesktop(desktop: boolean) { + window.matchMedia = vi.fn((query: string) => ({ + matches: desktop && query.includes('min-width: 1024px'), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })) as unknown as typeof window.matchMedia; +} + +function shell(props: { authenticated: boolean; isStalwartAutoLogin: boolean }) { + return ( + {}}> +

page

+
+ ); +} + +describe('AdminShell', () => { + beforeEach(() => { + setDesktop(true); + Element.prototype.scrollTo = () => {}; + }); + + it('does not server-render the phone layout, which desktop would show until hydration', () => { + const html = renderToString(shell({ authenticated: false, isStalwartAutoLogin: false })); + expect(html).not.toContain('Back to mail'); + expect(html).not.toContain('Search admin'); + }); + + it('draws the desktop sidebar once on the client', () => { + render(shell({ authenticated: true, isStalwartAutoLogin: false })); + expect(screen.getByText('Back to Mail')).toBeTruthy(); + }); + + it('hides the session links until the check says which kind of admin this is', () => { + const { rerender } = render(shell({ authenticated: false, isStalwartAutoLogin: false })); + expect(screen.queryByText('Sign out of admin')).toBeNull(); + rerender(shell({ authenticated: true, isStalwartAutoLogin: true })); + expect(screen.queryByText('Sign out of admin')).toBeNull(); + rerender(shell({ authenticated: true, isStalwartAutoLogin: false })); + expect(screen.getByText('Sign out of admin')).toBeTruthy(); + expect(screen.getByText('Change password')).toBeTruthy(); + }); +}); diff --git a/components/admin/__tests__/flash-message.test.tsx b/components/admin/__tests__/flash-message.test.tsx new file mode 100644 index 000000000..d0729a620 --- /dev/null +++ b/components/admin/__tests__/flash-message.test.tsx @@ -0,0 +1,31 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Flash } from '../flash-message'; + +describe('Flash', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('renders nothing without a message', () => { + const { container } = render( {}} />); + expect(container.firstChild).toBeNull(); + }); + + it('shows an error as an alert until dismissed', () => { + const onDismiss = vi.fn(); + render(); + expect(screen.getByRole('alert').textContent).toContain('Save failed'); + act(() => { vi.advanceTimersByTime(10_000); }); + expect(onDismiss).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('lets a success go away by itself', () => { + const onDismiss = vi.fn(); + render(); + expect(screen.getByRole('status').textContent).toContain('Saved'); + act(() => { vi.advanceTimersByTime(6_000); }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/admin/__tests__/save-bar.test.tsx b/components/admin/__tests__/save-bar.test.tsx new file mode 100644 index 000000000..a29cc6e59 --- /dev/null +++ b/components/admin/__tests__/save-bar.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AdminSaveBar } from '../save-bar'; +import { AdminShellContext } from '../shell-context'; + +describe('AdminSaveBar', () => { + it('shows nothing while there is nothing to save', () => { + render( {}} />); + expect(screen.queryByTestId('admin-save-bar')).toBeNull(); + }); + + it('offers Save and Discard once something changed', () => { + const onSave = vi.fn(); + const onDiscard = vi.fn(); + render(); + expect(screen.getByText('Unsaved changes')).toBeTruthy(); + expect(screen.getByText(/Users see it on login/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Save policy' })); + expect(onSave).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Discard' })); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('saves on Ctrl+S, but not while a save is running', () => { + const onSave = vi.fn(); + const { rerender } = render(); + fireEvent.keyDown(window, { key: 's', ctrlKey: true }); + expect(onSave).toHaveBeenCalledTimes(1); + rerender(); + fireEvent.keyDown(window, { key: 's', ctrlKey: true }); + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it('ignores Ctrl+S when nothing is dirty', () => { + const onSave = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 's', ctrlKey: true }); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('renders into the shell host when there is one', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + render( + {} }}> +
{}} />
+
, + ); + expect(host.querySelector('[data-testid="admin-save-bar"]')).not.toBeNull(); + expect(screen.getByTestId('page').querySelector('[data-testid="admin-save-bar"]')).toBeNull(); + host.remove(); + }); +}); diff --git a/components/admin/admin-link.tsx b/components/admin/admin-link.tsx new file mode 100644 index 000000000..5e81adb73 --- /dev/null +++ b/components/admin/admin-link.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { forwardRef, type AnchorHTMLAttributes, type MouseEvent } from 'react'; +import { withBasePath } from '@/lib/browser-navigation'; +import { navigateAdmin } from '@/hooks/use-admin-navigation'; + +interface AdminLinkProps extends AnchorHTMLAttributes { + /** App-relative admin path, e.g. `/admin/plugins/jitsi-meet`. */ + href: string; + replace?: boolean; +} + +/** + * A link between admin pages. A real anchor, so it can be opened in a new + * tab and the unsaved-changes guard sees it like any other link; a plain + * click moves within the panel through history.pushState instead of a + * route transition. + */ +export const AdminLink = forwardRef(function AdminLink( + { href, replace, onClick, target, children, ...rest }, + ref, +) { + const handleClick = (event: MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (target && target !== '_self') return; + event.preventDefault(); + navigateAdmin(href, { replace }); + }; + return ( + + {children} + + ); +}); diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx new file mode 100644 index 000000000..e78a3ec63 --- /dev/null +++ b/components/admin/admin-shell.tsx @@ -0,0 +1,454 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; +import { + ArrowLeft, + ArrowUpCircle, + Activity, + ChevronRight, + KeyRound, + LayoutDashboard, + LogOut, + Palette, + Puzzle, + Scale, + ScrollText, + Search, + Server, + Shield, + Store, + SwatchBook, + X, + type AppIcon, +} from '@/components/icons'; +import { Input } from '@/components/ui/input'; +import { NavigationRail } from '@/components/layout/navigation-rail'; +import { AboutFooter } from '@/components/settings/about-data-settings'; +import { scrollToSettingsSection } from '@/components/settings/settings-section'; +import { useIsDesktop } from '@/hooks/use-media-query'; +import { useAdminLocation, navigateAdmin } from '@/hooks/use-admin-navigation'; +import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; +import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store'; +import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; +import { ADMIN_PAGES, adminPath, type AdminPageDef, type AdminPageId, type AdminSectionDef } from '@/lib/admin/admin-nav'; +import { cn } from '@/lib/utils'; +import { AdminLink } from './admin-link'; +import { AdminShellContext } from './shell-context'; + +const PAGE_ICONS: Record = { + dashboard: LayoutDashboard, + settings: Server, + auth: Shield, + branding: Palette, + policy: Scale, + plugins: Puzzle, + themes: SwatchBook, + marketplace: Store, + version: ArrowUpCircle, + telemetry: Activity, + logs: ScrollText, +}; + +const GROUP_LABELS: Record = { + top: null, + extensions: 'Extensions', + system: 'System', +}; + +interface AdminShellProps { + /** Whether the admin session is in place; the sidebar counts load then. */ + authenticated: boolean; + /** Stalwart admins signed in automatically have no password to change. */ + isStalwartAutoLogin: boolean; + configReadOnly: boolean; + onLogout: () => void; + children: ReactNode; +} + +interface Match { + page: AdminPageDef; + sections: AdminSectionDef[]; +} + +const subscribeNothing = () => () => {}; + +/** + * The admin panel's frame: the webmail's own navigation rail, the page list + * laid out like the settings sidebar (search included), the content column + * and, on phones, the settings' list-then-page flow. + */ +export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, onLogout, children }: AdminShellProps) { + const pathname = usePathname(); + const location = useAdminLocation(); + const isDesktop = useIsDesktop(); + // The server can't know the window width, so its HTML would be the phone + // layout, shown on desktop until hydration swaps it (the rail's Admin link + // is a full page load). + const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false); + const requestLeave = useAdminUnsavedStore((s) => s.requestLeave); + const [query, setQuery] = useState(''); + const [saveBarHost, setSaveBarHost] = useState(null); + const [pendingApprovals, setPendingApprovals] = useState(0); + const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null); + const scrollRef = useRef(null); + + const onChangePassword = pathname === '/admin/change-password'; + const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null); + const activeDef = location.page ? ADMIN_PAGES.find((p) => p.id === location.page) ?? null : null; + + // 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'; + + const refreshBadges = useCallback(() => { + if (!authenticated) return; + apiFetch('/api/admin/plugin-approvals') + .then((res) => (res.ok ? res.json() : null)) + .then((data: { entries?: Array<{ status: string }> } | null) => { + setPendingApprovals(data?.entries?.filter((e) => e.status === 'pending').length ?? 0); + }) + .catch(() => {}); + }, [authenticated]); + useEffect(() => { refreshBadges(); }, [refreshBadges]); + + // A new page starts at the top; the back button restores nothing finer. + const scrollKey = `${location.page ?? ''}/${location.detail ?? ''}`; + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [scrollKey]); + + // A section picked from the search scrolls into view once its page has + // rendered it; pages fetch first, so retry for a moment. + useEffect(() => { + if (!pendingSection || pendingSection.page !== location.page) return; + let cancelled = false; + let timer: ReturnType | undefined; + const deadline = Date.now() + 2000; + const attempt = () => { + if (cancelled) return; + if (scrollToSettingsSection(pendingSection.id)) { setPendingSection(null); return; } + if (Date.now() < deadline) timer = setTimeout(attempt, 80); + else setPendingSection(null); + }; + const raf = window.requestAnimationFrame(attempt); + return () => { + cancelled = true; + window.cancelAnimationFrame(raf); + if (timer) clearTimeout(timer); + }; + }, [pendingSection, location.page]); + + const trimmed = query.trim().toLowerCase(); + const matches = useMemo(() => { + if (!trimmed) return ADMIN_PAGES.map((page) => ({ page, sections: [] })); + const words = trimmed.split(/\s+/); + const hits = (text: string) => words.every((w) => text.includes(w)); + return ADMIN_PAGES.flatMap((page) => { + const sections = page.sections.filter((s) => hits(`${s.title} ${s.keywords ?? ''}`.toLowerCase())); + const pageHit = hits(`${page.label} ${page.description} ${page.keywords}`.toLowerCase()); + if (!pageHit && sections.length === 0) return []; + return [{ page, sections: sections.slice(0, 5) }]; + }); + }, [trimmed]); + + const groups = useMemo(() => ( + (['top', 'extensions', 'system'] as const) + .map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) })) + .filter((g) => g.items.length > 0) + ), [matches]); + + const goTo = (page: AdminPageId, section?: string) => { + requestLeave(() => { + setPendingSection(section ? { page, id: section } : null); + navigateAdmin(adminPath(page)); + setQuery(''); + }); + }; + + const prefix = getPathPrefix(); + const contextValue = useMemo(() => ({ saveBarHost, refreshBadges }), [saveBarHost, refreshBadges]); + + const badgeFor = (id: AdminPageId): ReactNode => { + if (id === 'plugins' && pendingApprovals > 0) { + return ( + + {pendingApprovals} + + ); + } + if (id === 'version' && hasUpdate) { + return ( + + ); + } + return null; + }; + + const searchBox = ( +
+ + setQuery(e.target.value)} + placeholder="Search admin" + aria-label="Search admin pages" + className={cn('h-9 ps-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none', query && 'pe-8')} + /> + {query && ( + + )} +
+ ); + + const noResults = ( +
Nothing matches
+ ); + + // The admin session is its own: a password admin signs out of the panel, + // not of their mailbox, so these stay with the panel rather than the rail. + // A Stalwart admin signed in automatically has neither a password to change + // nor a session worth ending, since opening the panel signs them back in. + // Which kind of admin this is is known once the session check is done. + const showSessionLinks = authenticated && !isStalwartAutoLogin; + const sessionLinks = (mobile: boolean) => ( + <> + + + Change password + + + + ); + + const readOnlyBanner = configReadOnly && authenticated && ( +
+

The configuration is read-only

+

+ ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes. + Change them in the mounted config files or environment instead. +

+
+ ); + + if (!hydrated) return
; + + if (!isDesktop) { + const showIndex = location.page === null && !onChangePassword; + const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin'; + const backHref = location.detail ? adminPath(location.page) : '/admin'; + return ( + +
+
+ {showIndex ? ( + + + + ) : ( + + + + )} +
+ {showIndex && } +

{title}

+
+
+ +
+ {showIndex ? ( + <> +
{searchBox}
+
+ {groups.length === 0 && noResults} + {groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 &&
} + {group.label && ( +
+ {group.label} +
+ )} + {group.items.map(({ page, sections }) => { + const Icon = PAGE_ICONS[page.id]; + return ( +
+ { e.preventDefault(); goTo(page.id); }} + data-testid={`admin-page-${page.id}`} + className="flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-foreground transition-colors duration-150 hover:bg-muted" + > + + + {page.label} + {badgeFor(page.id)} + + + + {sections.map((section) => ( + + ))} +
+ ); + })} +
+ ))} +
+ {showSessionLinks &&
{sessionLinks(true)}
} +
+ + ) : ( +
+ {readOnlyBanner} + {children} +
+ )} +
+ +
+ + +
+ + ); + } + + return ( + +
+ {/* The same rail as mail and settings, with Admin as the current app. */} +
+ +
+ + {/* The page list, laid out like the settings sidebar. */} + + +
+
+
+ {readOnlyBanner} + {children} +
+
+
+
+
+
+ ); +} diff --git a/components/admin/badge.tsx b/components/admin/badge.tsx new file mode 100644 index 000000000..d667ff29d --- /dev/null +++ b/components/admin/badge.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'info' | 'muted' | 'accent'; + +const TONES: Record = { + success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400', + warning: 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400', + danger: 'bg-destructive/10 text-destructive', + info: 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400', + muted: 'bg-muted text-muted-foreground', + accent: 'bg-primary/10 text-primary', +}; + +interface BadgeProps { + tone?: BadgeTone; + icon?: ReactNode; + children: ReactNode; + title?: string; + className?: string; +} + +/** A small label next to a name: a state, a source, a count. */ +export function Badge({ tone = 'muted', icon, children, title, className }: BadgeProps) { + return ( + + {icon} + {children} + + ); +} diff --git a/components/admin/button-link.tsx b/components/admin/button-link.tsx new file mode 100644 index 000000000..e36274c58 --- /dev/null +++ b/components/admin/button-link.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { AnchorHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; +import { AdminLink } from './admin-link'; + +const VARIANTS = { + default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + ghost: 'hover:bg-accent hover:text-accent-foreground', +} as const; + +interface AdminButtonLinkProps extends AnchorHTMLAttributes { + href: string; + variant?: keyof typeof VARIANTS; + /** A link out of the panel (a hard navigation) rather than to another admin page. */ + external?: boolean; +} + +/** A link that looks like a small Button, for actions that open a page. */ +export function AdminButtonLink({ href, variant = 'outline', external, className, children, ...rest }: AdminButtonLinkProps) { + const classes = cn( + 'inline-flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-all duration-200', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + VARIANTS[variant], + className, + ); + if (external) { + return {children}; + } + return {children}; +} diff --git a/components/admin/config-items.tsx b/components/admin/config-items.tsx new file mode 100644 index 000000000..dc508cd16 --- /dev/null +++ b/components/admin/config-items.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useId, type ReactNode } from 'react'; +import { RotateCcw } from '@/components/icons'; +import { SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { cn } from '@/lib/utils'; +import { Badge } from './badge'; + +/** + * Rows for the server configuration pages (Server, Authentication, + * Branding). Each shows where the value comes from and offers to drop an + * admin override; the page keeps the edits and saves them as one. + */ + +export type ConfigSource = 'admin' | 'env' | 'default'; + +export const inputClass = + 'h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground ' + + 'hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring ' + + 'disabled:cursor-not-allowed disabled:opacity-50 transition-colors'; + +export function SourceBadge({ source, label }: { source?: string; label?: string }) { + if (!source || source === 'default') return null; + return ( + + {label ?? source} + + ); +} + +export function RevertButton({ onClick, title = 'Revert to default' }: { onClick: () => void; title?: string }) { + return ( + + ); +} + +interface ConfigItemBase { + label: string; + description?: string; + configKey: string; + source?: string; + onChange: (key: string, value: unknown) => void; + onRevert?: (key: string) => void; + /** Rows that only matter in unusual setups, hidden behind the Advanced switch. */ + advanced?: boolean; + disabled?: boolean; +} + +interface ConfigTextItemProps extends ConfigItemBase { + value: string | undefined | null; + placeholder?: string; + type?: 'text' | 'url' | 'password' | 'number'; + mono?: boolean; + /** Wider field, for URLs and lists. */ + wide?: boolean; +} + +export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled }: ConfigTextItemProps) { + const id = useId(); + return ( + +
+ + onChange(configKey, e.target.value)} + placeholder={placeholder} + aria-label={label} + autoComplete="off" + spellCheck={false} + className={cn(inputClass, 'w-full min-w-0', wide ? 'sm:w-80' : 'sm:w-64', mono && 'font-mono')} + /> + {source === 'admin' && onRevert && onRevert(configKey)} />} +
+
+ ); +} + +interface ConfigToggleItemProps extends ConfigItemBase { + value: boolean | undefined | null; +} + +export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled }: ConfigToggleItemProps) { + return ( + +
+ + onChange(configKey, checked)} /> + {source === 'admin' && onRevert && onRevert(configKey)} />} +
+
+ ); +} + +interface ConfigSelectItemProps extends ConfigItemBase { + value: string | undefined | null; + options: ReadonlyArray; +} + +export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled }: ConfigSelectItemProps) { + const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)); + return ( + +
+ + + {source === 'admin' && onRevert && onRevert(configKey)} />} +
+
+ ); +} + +/** A note under a group of rows, for a warning or an explanation. */ +export function ConfigNote({ tone = 'info', children }: { tone?: 'info' | 'warning'; children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/components/admin/empty-state.tsx b/components/admin/empty-state.tsx new file mode 100644 index 000000000..397b5ab9b --- /dev/null +++ b/components/admin/empty-state.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; +import type { AppIcon } from '@/components/icons'; +import { cn } from '@/lib/utils'; + +interface EmptyStateProps { + icon?: AppIcon; + title: ReactNode; + hint?: ReactNode; + action?: ReactNode; + className?: string; +} + +/** A list with nothing in it, with what to do about that. */ +export function EmptyState({ icon: Icon, title, hint, action, className }: EmptyStateProps) { + return ( +
+ {Icon && } +

{title}

+ {hint &&

{hint}

} + {action &&
{action}
} +
+ ); +} diff --git a/components/admin/extension-icon.tsx b/components/admin/extension-icon.tsx new file mode 100644 index 000000000..62268fb50 --- /dev/null +++ b/components/admin/extension-icon.tsx @@ -0,0 +1,17 @@ +import type { AppIcon } from '@/components/icons'; + +interface ExtensionIconProps { + /** The brand icon the extension directory serves, when known. */ + url: string | null | undefined; + /** What to draw without one. */ + fallback: AppIcon; + className?: string; +} + +/** A plugin's or theme's icon, or the generic one when the directory has none. */ +export function ExtensionIcon({ url, fallback: Fallback, className }: ExtensionIconProps) { + if (url) { + return ; + } + return ; +} diff --git a/components/admin/flash-message.tsx b/components/admin/flash-message.tsx new file mode 100644 index 000000000..b91d6ccb8 --- /dev/null +++ b/components/admin/flash-message.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { CheckCircle2, AlertTriangle, X } from '@/components/icons'; +import { cn } from '@/lib/utils'; + +export type FlashMessage = { type: 'success' | 'error'; text: string } | null; + +/** Page-level feedback state; the message is shown with ``. */ +export function useFlash(): [FlashMessage, Dispatch>] { + return useState(null); +} + +interface FlashProps { + message: FlashMessage; + onDismiss: () => void; + className?: string; +} + +/** + * The outcome of the last action, under the page header. A success goes + * away by itself; an error stays until dismissed or the next action. + */ +export function Flash({ message, onDismiss, className }: FlashProps) { + useEffect(() => { + if (message?.type !== 'success') return; + const timer = setTimeout(onDismiss, 6000); + return () => clearTimeout(timer); + }, [message, onDismiss]); + + if (!message) return null; + const success = message.type === 'success'; + const Icon = success ? CheckCircle2 : AlertTriangle; + return ( +
+ + {message.text} + +
+ ); +} diff --git a/components/admin/list.tsx b/components/admin/list.tsx new file mode 100644 index 000000000..ad3269c1d --- /dev/null +++ b/components/admin/list.tsx @@ -0,0 +1,74 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { ChevronRight } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { AdminLink } from './admin-link'; + +interface AdminListProps { + children: ReactNode; + className?: string; +} + +/** Rows of things the admin manages: plugins, themes, requests. */ +export function AdminList({ children, className }: AdminListProps) { + return ( +
+ {children} +
+ ); +} + +interface AdminListRowProps { + /** Makes the whole row open this admin path; the title becomes the link. */ + href?: string; + icon?: ReactNode; + title: ReactNode; + /** Badges and other inline bits after the title. */ + titleExtra?: ReactNode; + description?: ReactNode; + /** The small line under the description: author, version, dates. */ + meta?: ReactNode; + /** Controls on the end side; they sit above the row link. */ + control?: ReactNode; + className?: string; + 'data-testid'?: string; +} + +export function AdminListRow({ href, icon, title, titleExtra, description, meta, control, className, ...rest }: AdminListRowProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ {href ? ( + + {title} + + ) : ( + {title} + )} + {titleExtra} +
+ {description &&

{description}

} + {meta &&
{meta}
} +
+ {control &&
{control}
} + {href &&
+ ); +} diff --git a/components/admin/page-header.tsx b/components/admin/page-header.tsx new file mode 100644 index 000000000..ed367948e --- /dev/null +++ b/components/admin/page-header.tsx @@ -0,0 +1,50 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { ArrowLeft } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { AdminLink } from './admin-link'; + +interface AdminPageHeaderProps { + title: ReactNode; + description?: ReactNode; + /** Buttons on the end side of the header. */ + actions?: ReactNode; + /** A link back to the parent page, for detail pages. */ + back?: { href: string; label: string }; + /** Something between the back link and the title, such as an icon. */ + leading?: ReactNode; + className?: string; +} + +/** + * The header of an admin page. The mobile shell already shows the page name + * in its top bar, so the title itself is desktop-only there; the + * description and the actions stay. + */ +export function AdminPageHeader({ title, description, actions, back, leading, className }: AdminPageHeaderProps) { + return ( +
+ {back && ( + + + {back.label} + + )} +
+
+ {leading} +
+

{title}

+ {description &&

{description}

} +
+
+ {actions &&
{actions}
} +
+
+ ); +} diff --git a/components/admin/save-bar.tsx b/components/admin/save-bar.tsx new file mode 100644 index 000000000..3dd834ed5 --- /dev/null +++ b/components/admin/save-bar.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { Loader2 } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { useAdminShell } from './shell-context'; + +interface AdminSaveBarProps { + dirty: boolean; + saving?: boolean; + onSave: () => void; + /** Drops the edits; without it the bar offers only Save. */ + onDiscard?: () => void; + saveLabel?: string; + /** What the save does, e.g. "Users see the change on their next login". */ + note?: ReactNode; +} + +/** + * The one place a page saves from. It appears at the bottom of the panel + * while something is unsaved (through the shell's portal host, so it sits + * below the scrolling content and above the mobile rail) and takes Ctrl+S. + */ +export function AdminSaveBar({ dirty, saving = false, onSave, onDiscard, saveLabel = 'Save changes', note }: AdminSaveBarProps) { + const host = useAdminShell()?.saveBarHost ?? null; + + useEffect(() => { + if (!dirty) return; + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + if (!saving) onSave(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [dirty, saving, onSave]); + + if (!dirty) return null; + + const bar = ( +
+
+
+ Unsaved changes + {note && · {note}} +
+
+ {onDiscard && ( + + )} + +
+
+
+ ); + + return host ? createPortal(bar, host) : bar; +} diff --git a/components/admin/shell-context.tsx b/components/admin/shell-context.tsx new file mode 100644 index 000000000..19d82e74a --- /dev/null +++ b/components/admin/shell-context.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export interface AdminShellContextValue { + /** Where a page's save bar renders: below the scrolling content, above the mobile rail. */ + saveBarHost: HTMLElement | null; + /** Re-reads the counts shown next to sidebar entries (pending approvals). */ + refreshBadges: () => void; +} + +export const AdminShellContext = createContext(null); + +export function useAdminShell(): AdminShellContextValue | null { + return useContext(AdminShellContext); +} diff --git a/components/auth/__tests__/mobile-handoff-consent.test.tsx b/components/auth/__tests__/mobile-handoff-consent.test.tsx new file mode 100644 index 000000000..cbdb6cf22 --- /dev/null +++ b/components/auth/__tests__/mobile-handoff-consent.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values?.username ? `${key}:${values.username}` : key, +})); + +import { MobileHandoffConsent } from '../mobile-handoff-consent'; + +// A sign-in started with `mobile_redirect_uri` used to navigate to the app +// link the moment it succeeded, password or tokens in the fragment. The user +// must confirm first, and nothing navigates until they do. + +const URL_TO_APP = 'bulwarkmobile://auth/callback#flow=password&password=secret'; + +let replaced: string[]; + +beforeEach(() => { + replaced = []; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, replace: (url: string) => { replaced.push(url); } }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('MobileHandoffConsent', () => { + it('shows what will happen and navigates only on confirmation', () => { + render( {}} />); + expect(screen.getByText('mobile_handoff.title')).toBeTruthy(); + expect(screen.getByText('mobile_handoff.description:alice@example.org')).toBeTruthy(); + expect(replaced).toEqual([]); + + fireEvent.click(screen.getByText('mobile_handoff.confirm')); + expect(replaced).toEqual([URL_TO_APP]); + }); + + it('keeps the user in the webmail when they decline', () => { + const onCancel = vi.fn(); + render(); + fireEvent.click(screen.getByText('mobile_handoff.cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(replaced).toEqual([]); + }); + + it('renders nothing without a pending handoff', () => { + render( {}} />); + expect(screen.queryByText('mobile_handoff.title')).toBeNull(); + }); +}); diff --git a/components/auth/mobile-handoff-consent.tsx b/components/auth/mobile-handoff-consent.tsx new file mode 100644 index 000000000..cc0a00266 --- /dev/null +++ b/components/auth/mobile-handoff-consent.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; + +interface MobileHandoffConsentProps { + /** The app link the browser will open, with the credential material in its fragment. */ + url: string | null; + /** The account being handed over, for the explanation. */ + username: string; + /** The user stays in the webmail instead. */ + onCancel: () => void; +} + +/** + * The step between a sign-in that was started with `mobile_redirect_uri` and + * the navigation that hands its password or tokens to the mobile app. The + * page otherwise reads exactly like the ordinary sign-in page, and a link + * carrying that parameter is all it takes to open it; whoever owns the + * `bulwarkmobile` scheme on the device receives whatever is handed over + * (lib/auth/mobile-handoff.ts). Nothing leaves without this confirmation. + */ +export function MobileHandoffConsent({ url, username, onCancel }: MobileHandoffConsentProps) { + const t = useTranslations("login"); + return ( + { + if (url) window.location.replace(url); + }} + title={t("mobile_handoff.title")} + message={t("mobile_handoff.description", { username })} + confirmText={t("mobile_handoff.confirm")} + cancelText={t("mobile_handoff.cancel")} + /> + ); +} diff --git a/components/calendar/__tests__/calendar-month-view-header.test.tsx b/components/calendar/__tests__/calendar-month-view-header.test.tsx new file mode 100644 index 000000000..145e33655 --- /dev/null +++ b/components/calendar/__tests__/calendar-month-view-header.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { CalendarMonthView } from '../calendar-month-view'; +import type { Calendar } from '@/lib/jmap/types'; + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +describe('CalendarMonthView header layout', () => { + afterEach(() => { + cleanup(); + }); + + it('renders the day headers inside the scrollable container as a sticky row', () => { + render( + , + ); + + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(7); + + // The header row should be sticky top-0 and inside the scroll container + const headerRow = headers[0].closest('[role="row"]'); + expect(headerRow).not.toBeNull(); + expect(headerRow).toHaveClass('sticky', 'top-0', 'z-50', 'bg-background'); + + // The header row must be a child of the scroll container + const scroller = document.querySelector('.overflow-y-auto'); + expect(scroller).not.toBeNull(); + expect(scroller).toContainElement(headerRow); + + // Grid cells should also be inside the same scroll container + const gridCells = screen.getAllByRole('gridcell'); + expect(gridCells.length).toBeGreaterThan(0); + expect(scroller).toContainElement(gridCells[0]); + }); +}); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index fdca8cbfb..7cbe2e871 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -161,21 +161,29 @@ export function CalendarMonthView({ }, [events, weeks, tasksByDate]); const scrollRef = useRef(null); + const headerRef = useRef(null); const topSentinelRef = useRef(null); const bottomSentinelRef = useRef(null); // Six rows fill the viewport, as a single month used to; taller rows grow // with their chips. const [viewportHeight, setViewportHeight] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); useLayoutEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => setViewportHeight(el.clientHeight)); + const observer = new ResizeObserver(() => { + setViewportHeight(el.clientHeight); + if (headerRef.current) setHeaderHeight(headerRef.current.offsetHeight); + }); observer.observe(el); + if (headerRef.current) observer.observe(headerRef.current); setViewportHeight(el.clientHeight); + if (headerRef.current) setHeaderHeight(headerRef.current.offsetHeight); return () => observer.disconnect(); }, []); - const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(viewportHeight / 6)); + const availableHeight = Math.max(0, viewportHeight - headerHeight); + const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(availableHeight / 6)); // On wide screens a week keeps its height: a day with more entries than // fit shows the first ones and a "N more" link (as in Google Calendar). // Phones keep growing the row to fit. @@ -191,7 +199,8 @@ export function CalendarMonthView({ const sampleVisibleMonth = useCallback(() => { const el = scrollRef.current; if (!el) return; - const sampleY = el.getBoundingClientRect().top + el.clientHeight * VISIBLE_MONTH_SAMPLE; + const hHeight = headerRef.current?.offsetHeight ?? 0; + const sampleY = el.getBoundingClientRect().top + hHeight + (el.clientHeight - hHeight) * VISIBLE_MONTH_SAMPLE; const rows = el.querySelectorAll("[data-week]"); let hit: HTMLElement | null = null; for (const row of rows) { @@ -223,7 +232,8 @@ export function CalendarMonthView({ const targetKey = dayKey(grid[0] ?? focus.date); const row = el.querySelector(`[data-week="${targetKey}"]`); if (row) { - el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top; + const hHeight = headerRef.current?.offsetHeight ?? 0; + el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top - hHeight; } visibleMonthRef.current = focus.date; setVisibleMonthDate(focus.date); @@ -285,22 +295,25 @@ export function CalendarMonthView({ return (
-
- {dayHeaderKeys.map((d) => ( -
- {isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} -
- ))} -
-
+
+ {dayHeaderKeys.map((d) => ( +
+ {isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} +
+ ))} +
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => { const overflowing = capRows && contentRows > rowCapacity; diff --git a/components/calendar/calendar-sidebar-header.tsx b/components/calendar/calendar-sidebar-header.tsx index 79272d9aa..e9120d91a 100644 --- a/components/calendar/calendar-sidebar-header.tsx +++ b/components/calendar/calendar-sidebar-header.tsx @@ -18,10 +18,9 @@ export function CalendarSidebarHeader({ onCreateEvent, tourTarget = true }: Cale const t = useTranslations("calendar"); return ( -
+ // Same h-14 box as the mail sidebar's account switcher, so the apps line up; + // box-content keeps the border outside it, as in mail. +
{t("title")}
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 92df5ece6..8747244ef 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -42,9 +42,26 @@ interface ContactDetailProps { className?: string; } -function formatPhoneFeatures(features?: Record): string { +// Contexts and phone features the contact form offers, shown with the same +// labels here. Anything else (custom vCard types) is shown as stored. +const CONTEXT_LABEL_KEYS: Record = { + work: "form.context_work", + private: "form.context_private", +}; + +const PHONE_FEATURE_LABEL_KEYS: Record = { + voice: "form.phone_voice", + cell: "form.phone_cell", + mobile: "form.phone_cell", + fax: "form.phone_fax", + pager: "form.phone_pager", + video: "form.phone_video", + text: "form.phone_text", +}; + +function formatPhoneFeatures(features: Record | undefined, label: (key: string) => string): string { if (!features) return ""; - return Object.keys(features).filter(k => features[k]).join(", "); + return Object.keys(features).filter(k => features[k]).map(label).join(", "); } function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } { @@ -123,6 +140,8 @@ function formatDate(dateInput: AnniversaryDate): string { export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); + const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key); + const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key); const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0; @@ -249,7 +268,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{emails.map((e, i) => ( - +
{e.address} @@ -270,8 +289,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli ))} {phones.map((p, i) => { - const features = formatPhoneFeatures(p.features); - const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[]; + const features = formatPhoneFeatures(p.features, featureLabel); + const labelParts = [p.label, formatContexts(p.contexts, contextLabel), features].filter(Boolean) as string[]; return (
@@ -300,7 +319,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli lines.push(...parts); } return ( - +
{lines.map((line, idx) => (
{line}
@@ -317,7 +336,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( @@ -399,7 +418,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{lang.language}
@@ -528,9 +547,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli ); } -function formatContexts(contexts?: Record): string { +function formatContexts(contexts: Record | undefined, label: (key: string) => string): string { if (!contexts) return ""; - return Object.keys(contexts).filter(k => contexts[k]).join(", "); + return Object.keys(contexts).filter(k => contexts[k]).map(label).join(", "); } export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) { diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 7da45a464..f736593a7 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -278,8 +278,8 @@ export function ContactList({
{/* Toolbar: select / search / filter */}
-
-
+
+
{onMenuClick && (
@@ -1041,6 +1077,35 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = )} style={!isMobile ? { width: `${listWidth}px` } : undefined} > + {activeCategory === "directory" ? ( + <> + { setOpenDirectoryKey(directoryKey(entry)); setView("directory-detail"); }} + activeKey={openDirectoryKey} + onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined} + className="flex-1" + /> + {directorySelection.size > 0 && ( +
+ + {t("directory.selected_count", { count: directorySelection.size })} + + + +
+ )} + + ) : ( setNarrowSidebarOpen(true) : undefined} isLoading={supportsSync && !contactsLoaded} /> + )}
{!isMobile && ( diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index ccdff5024..c40243b5a 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "@/components/icons"; +import { BookUser, Building2, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail, Star } from "@/components/icons"; import { useRouter } from "@/i18n/navigation"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; @@ -12,7 +12,7 @@ import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; import { useAccountStore } from "@/stores/account-store"; -export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; +export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; interface ContactsSidebarProps { groups: ContactCard[]; @@ -35,6 +35,8 @@ interface ContactsSidebarProps { onCreateContactInBook?: (addressBook: AddressBook) => void; onDeleteAddressBook?: (addressBook: AddressBook) => void; onRenameKeyword?: (keyword: string) => void; + /** Entries in the company directory; the Directory entry shows only when set. */ + directoryCount?: number; className?: string; /** * Pro shell: render one collapsible section per connected local account @@ -106,6 +108,7 @@ export function ContactsSidebar({ onCreateContactInBook, onDeleteAddressBook, onRenameKeyword, + directoryCount, className, multiAccountMode, }: ContactsSidebarProps) { @@ -272,7 +275,8 @@ export function ContactsSidebar({ return (
{/* Header */} -
+ {/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */} +
{t("title")}
+ {/* Company directory (server principals), read-only */} + {directoryCount !== undefined && ( + + )} + {/* Address Books: per-account groups in multi-account Pro mode, else the classic "My Address Books" section. */} {multiAccountMode && localAccountGroups.length > 0 ? ( diff --git a/components/contacts/directory-browser.tsx b/components/contacts/directory-browser.tsx new file mode 100644 index 000000000..86d0bab2c --- /dev/null +++ b/components/contacts/directory-browser.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations, useLocale } from "next-intl"; +import { Search, X, RefreshCw, Loader2, Lock, AlertCircle, BookUser, CheckSquare, Square, ChevronLeft, ChevronRight, Menu, Users, MapPin, Box } from "@/components/icons"; +import { Input } from "@/components/ui/input"; +import { Avatar } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { useContactStore, type DirectoryEntry } from "@/stores/contact-store"; + +export const DIRECTORY_PAGE_SIZE = 50; + +/** Case-insensitive key of a directory entry, used for selection. */ +export function directoryKey(entry: DirectoryEntry): string { + return entry.email.toLowerCase(); +} + +export function filterDirectory(entries: DirectoryEntry[], query: string, locale?: string): DirectoryEntry[] { + const q = query.trim().toLowerCase(); + const matched = q + ? entries.filter((e) => + e.name.toLowerCase().includes(q) || + e.email.toLowerCase().includes(q) || + (e.description?.toLowerCase().includes(q) ?? false)) + : entries; + const collator = new Intl.Collator(locale, { sensitivity: "base" }); + return [...matched].sort((a, b) => collator.compare(a.name, b.name) || collator.compare(a.email, b.email)); +} + +function TypeIcon({ type }: { type: DirectoryEntry["type"] }) { + if (type === "group") return ; + if (type === "location") return ; + if (type === "resource") return ; + return null; +} + +interface DirectoryBrowserProps { + client: IJMAPClient | null; + /** Keys (lowercased addresses) of the checked entries. */ + selected: Set; + onSelectedChange: (next: Set) => void; + /** Contacts view: clicking a row opens it. Without this, clicking toggles it. */ + onOpen?: (entry: DirectoryEntry) => void; + activeKey?: string | null; + onMenuClick?: () => void; + autoFocusSearch?: boolean; + className?: string; +} + +/** + * The company directory (RFC 9670 principals the server lists for this + * account), read-only: search, pages, multi-select. Used by the Contacts + * "Directory" entry and the composer's recipient picker. + */ +export function DirectoryBrowser({ + client, + selected, + onSelectedChange, + onOpen, + activeKey, + onMenuClick, + autoFocusSearch, + className, +}: DirectoryBrowserProps) { + const t = useTranslations("contacts.directory"); + const tContacts = useTranslations("contacts"); + const locale = useLocale(); + const entries = useContactStore((s) => s.directoryPrincipals); + const status = useContactStore((s) => s.directoryStatus); + const fetchDirectory = useContactStore((s) => s.fetchDirectory); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + const searchRef = useRef(null); + + // The directory loads at sign-in; fetch here only if that never ran. + useEffect(() => { + if (client && status === "idle") void fetchDirectory(client); + }, [client, status, fetchDirectory]); + + useEffect(() => { + if (autoFocusSearch) searchRef.current?.focus(); + }, [autoFocusSearch]); + + const filtered = useMemo(() => filterDirectory(entries, query, locale), [entries, query, locale]); + const pageCount = Math.max(1, Math.ceil(filtered.length / DIRECTORY_PAGE_SIZE)); + const currentPage = Math.min(page, pageCount - 1); + const pageEntries = filtered.slice(currentPage * DIRECTORY_PAGE_SIZE, (currentPage + 1) * DIRECTORY_PAGE_SIZE); + + const loading = status === "loading" || (status === "idle" && !!client); + const refresh = () => { if (client) void fetchDirectory(client); }; + + const toggle = (entry: DirectoryEntry) => { + const next = new Set(selected); + const key = directoryKey(entry); + if (next.has(key)) next.delete(key); + else next.add(key); + onSelectedChange(next); + }; + + const pageAllSelected = pageEntries.length > 0 && pageEntries.every((e) => selected.has(directoryKey(e))); + const togglePage = () => { + const next = new Set(selected); + for (const e of pageEntries) { + if (pageAllSelected) next.delete(directoryKey(e)); + else next.add(directoryKey(e)); + } + onSelectedChange(next); + }; + + let placeholder: React.ReactNode = null; + if (entries.length === 0) { + if (loading) { + placeholder = ( + } title={t("loading")} /> + ); + } else if (status === "unavailable" || !client) { + placeholder = } title={t("unavailable_title")} hint={t("unavailable_hint")} />; + } else if (status === "forbidden") { + placeholder = } title={t("forbidden_title")} hint={t("forbidden_hint")} onRetry={refresh} retryLabel={t("retry")} />; + } else if (status === "error") { + placeholder = } title={t("error_title")} hint={t("error_hint")} onRetry={refresh} retryLabel={t("retry")} />; + } else { + placeholder = } title={t("empty_title")} hint={t("empty_hint")} />; + } + } else if (filtered.length === 0) { + placeholder = } title={t("no_match")} />; + } + + return ( +
+
+
+ {onMenuClick && ( + + )} + +
+ + { setQuery(e.target.value); setPage(0); }} + className={cn("ps-9 h-9", query && "pe-8")} + aria-label={t("search_placeholder")} + /> + {query && ( + + )} +
+ +
+ {entries.length > 0 && (status === "error" || status === "forbidden") && ( +

{t("refresh_failed")}

+ )} +
+ +
+ {placeholder ?? pageEntries.map((entry) => { + const key = directoryKey(entry); + const checked = selected.has(key); + const showEmail = entry.name !== entry.email; + return ( +
(onOpen ? onOpen(entry) : toggle(entry))} + className={cn( + "w-full flex items-center gap-3 px-4 py-2 border-b border-border cursor-pointer select-none transition-colors", + activeKey === key ? "bg-selection" : "hover:bg-muted", + checked && activeKey !== key && "bg-selection/60", + )} + > + + +
+
+ {entry.name} + {entry.type && entry.type !== "individual" && ( + + + + )} +
+ {showEmail &&
{entry.email}
} +
+
+ ); + })} +
+ + {filtered.length > 0 && ( +
+ + {t("range", { + from: currentPage * DIRECTORY_PAGE_SIZE + 1, + to: currentPage * DIRECTORY_PAGE_SIZE + pageEntries.length, + total: filtered.length, + })} + + {pageCount > 1 && ( +
+ + {t("page", { page: currentPage + 1, count: pageCount })} + +
+ )} +
+ )} +
+ ); +} + +function StatePanel({ icon, title, hint, onRetry, retryLabel }: { + icon: React.ReactNode; + title: string; + hint?: string; + onRetry?: () => void; + retryLabel?: string; +}) { + return ( +
+ {icon} +

{title}

+ {hint &&

{hint}

} + {onRetry && ( + + )} +
+ ); +} diff --git a/components/contacts/directory-entry-detail.tsx b/components/contacts/directory-entry-detail.tsx new file mode 100644 index 000000000..c1435f5e8 --- /dev/null +++ b/components/contacts/directory-entry-detail.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Mail, UserPlus, BookUser } from "@/components/icons"; +import { Avatar } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import type { DirectoryEntry } from "@/stores/contact-store"; + +interface DirectoryEntryDetailProps { + entry: DirectoryEntry | null; + onCompose: (entry: DirectoryEntry) => void; + onAddToContacts: (entry: DirectoryEntry) => void; +} + +/** Read-only view of one directory entry. */ +export function DirectoryEntryDetail({ entry, onCompose, onAddToContacts }: DirectoryEntryDetailProps) { + const t = useTranslations("contacts.directory"); + + if (!entry) { + return ( +
+ +

{t("select_hint")}

+
+ ); + } + + return ( +
+
+ +
+

{entry.name}

+ {entry.type && ( +

{t(`type.${entry.type}`)}

+ )} +
+
+ + +
+
+
+
+
{t("email")}
+
{entry.email}
+
+
+

{t("read_only_note")}

+
+ ); +} diff --git a/components/contacts/directory-picker-dialog.tsx b/components/contacts/directory-picker-dialog.tsx new file mode 100644 index 000000000..493a84e3c --- /dev/null +++ b/components/contacts/directory-picker-dialog.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { X } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { useContactStore, type DirectoryEntry } from "@/stores/contact-store"; +import { DirectoryBrowser, directoryKey } from "./directory-browser"; + +export type RecipientField = "to" | "cc" | "bcc"; + +interface DirectoryPickerDialogProps { + client: IJMAPClient | null; + onClose: () => void; + /** Called with the checked entries in directory order; the dialog closes afterwards. */ + onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void; +} + +/** The composer's "Select from directory" dialog. */ +export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) { + const t = useTranslations("contacts.directory"); + const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true }); + const [selected, setSelected] = useState>(() => new Set()); + + const add = (field: RecipientField) => { + const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e))); + if (entries.length === 0) return; + onAdd(field, entries); + onClose(); + }; + + return ( +
+
+
+

+ {t("picker_title")} +

+ +
+ + + +
+ + {t("selected_count", { count: selected.size })} + + + + + +
+
+
+ ); +} diff --git a/components/email/__tests__/archive-gate-viewer.test.tsx b/components/email/__tests__/archive-gate-viewer.test.tsx new file mode 100644 index 000000000..8fb33636e --- /dev/null +++ b/components/email/__tests__/archive-gate-viewer.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// The admin policy's archiveActionEnabled gate (#1257): off, the viewer's +// toolbar has no Archive button. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); + +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: () => [], +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { usePolicyStore } = await import('@/stores/policy-store'); + +const mailboxes: Mailbox[] = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], + to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + preview: 'Hello', + hasAttachment: false, + size: 100, + textBody: [], + htmlBody: [], + attachments: [], + bodyValues: {}, + ...overrides, + } as unknown as Email; +} + +function setArchiveGate(on: boolean) { + usePolicyStore.setState((state) => ({ + policy: { ...state.policy, features: { ...state.policy.features, archiveActionEnabled: on } }, + })); +} + +afterEach(() => setArchiveGate(true)); + +function viewer() { + return render( + , + ); +} + +describe('the viewer and the Archive action gate', () => { + it('shows the Archive button while the gate is on', () => { + viewer(); + expect(screen.getByTitle('tooltips.archive')).toBeInTheDocument(); + }); + + it('has no Archive button with the gate off, Delete still there', () => { + setArchiveGate(false); + viewer(); + expect(screen.queryByTitle('tooltips.archive')).toBeNull(); + expect(screen.getByTitle('tooltips.delete')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/archive-gate.test.tsx b/components/email/__tests__/archive-gate.test.tsx new file mode 100644 index 000000000..039212b4f --- /dev/null +++ b/components/email/__tests__/archive-gate.test.tsx @@ -0,0 +1,165 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// The admin policy's archiveActionEnabled gate (#1257): off, Bulwark offers no +// Archive action anywhere. vitest.setup.ts renders labels as their raw keys. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => false })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) })); + +const { EmailContextMenu } = await import('../email-context-menu'); +const { EmailHoverActions } = await import('../email-hover-actions'); +const { MvListToolbar } = await import('../mv-list-toolbar'); +const { KeyboardShortcutsModal } = await import('@/components/keyboard-shortcuts-modal'); +const { usePolicyStore } = await import('@/stores/policy-store'); +const { useEmailStore, ensureArchiveMailbox } = await import('@/stores/email-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +function setArchiveGate(on: boolean) { + usePolicyStore.setState((state) => ({ + policy: { ...state.policy, features: { ...state.policy.features, archiveActionEnabled: on } }, + })); +} + +// Each test starts from the stores as they were before the first one. +const initial = { + policy: usePolicyStore.getState(), + email: useEmailStore.getState(), + auth: useAuthStore.getState(), + settings: useSettingsStore.getState(), +}; +afterEach(() => { + usePolicyStore.setState(initial.policy, true); + useEmailStore.setState(initial.email, true); + useAuthStore.setState(initial.auth, true); + useSettingsStore.setState(initial.settings, true); +}); + +const rights = { + mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, + maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, +}; +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, sortOrder: 0, totalEmails: 0, + unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: rights } as unknown as Mailbox; +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', hasAttachment: false, size: 100, +} as unknown as Email; + +function contextMenu(props: Partial> = {}) { + render( + , + ); +} + +describe('the Archive action gate', () => { + it('context menu: archive for one message while the gate is on', () => { + contextMenu(); + expect(screen.getByText('archive')).toBeInTheDocument(); + }); + + it('context menu: no archive entry for one message with the gate off', () => { + setArchiveGate(false); + contextMenu(); + expect(screen.queryByText('archive')).toBeNull(); + }); + + it('context menu: archive for a multi-selection while the gate is on', () => { + contextMenu({ isMultiSelect: true, selectedCount: 3 }); + expect(screen.getByText('archive')).toBeInTheDocument(); + }); + + it('context menu: no archive entry for a multi-selection with the gate off', () => { + setArchiveGate(false); + contextMenu({ isMultiSelect: true, selectedCount: 3 }); + expect(screen.queryByText('archive')).toBeNull(); + }); + + it('hover actions: the configured archive button is left out with the gate off', () => { + useSettingsStore.setState({ hoverActions: ['archive', 'delete'], interfaceLayout: 'default' } as never); + const { unmount } = render(); + expect(screen.getByTestId('hover-action-archive')).toBeInTheDocument(); + unmount(); + + setArchiveGate(false); + render(); + expect(screen.queryByTestId('hover-action-archive')).toBeNull(); + expect(screen.getByTestId('hover-action-delete')).toBeInTheDocument(); + }); + + it("Mountain View's selection bar: no archive button with the gate off", () => { + const emails = [{ ...email, id: 'a' }, { ...email, id: 'b' }] as Email[]; + useAuthStore.setState({ client: {} as never } as never); + useEmailStore.setState({ + emails, mailboxes: [inbox], selectedMailbox: 'inbox', + selectedEmailKeys: new Set(['a', 'b']), isUnifiedView: false, unifiedRole: null, + } as never); + const { unmount } = render( {}} />); + expect(screen.getByLabelText('archive')).toBeInTheDocument(); + unmount(); + + setArchiveGate(false); + render( {}} />); + expect(screen.queryByLabelText('archive')).toBeNull(); + }); + + it('shortcut help: the E row is listed only while the gate is on', () => { + const { unmount } = render( {}} />); + expect(screen.getByText('shortcuts.actions.archive')).toBeInTheDocument(); + unmount(); + + setArchiveGate(false); + render( {}} />); + expect(screen.queryByText('shortcuts.actions.archive')).toBeNull(); + }); + + it('the Archive folder: with the gate off none is created, an existing one not used', async () => { + setArchiveGate(false); + const client = { createMailbox: vi.fn() }; + const archive = { ...inbox, id: 'archive', name: 'Archive', role: 'archive' } as Mailbox; + const refresh = vi.fn(); + await expect(ensureArchiveMailbox({ client: client as never, mailboxes: [inbox], selectedMailboxId: 'inbox', refresh })) + .resolves.toBeNull(); + await expect(ensureArchiveMailbox({ client: client as never, mailboxes: [inbox, archive], selectedMailboxId: 'inbox', refresh })) + .resolves.toBeNull(); + expect(client.createMailbox).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('the Archive folder: with the gate on it is created when missing', async () => { + const archive = { ...inbox, id: 'archive', name: 'Archive', role: 'archive' } as Mailbox; + const client = { createMailbox: vi.fn().mockResolvedValue(archive) }; + await expect(ensureArchiveMailbox({ + client: client as never, mailboxes: [inbox], selectedMailboxId: 'inbox', + refresh: async () => [inbox, archive], + })).resolves.toMatchObject({ id: 'archive' }); + expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, undefined, { role: 'archive' }); + }); + + it('batch archive: with the gate off nothing is asked of the server', async () => { + setArchiveGate(false); + const request = vi.fn(); + const client = { request, createMailbox: vi.fn(), batchMoveEmails: vi.fn() }; + useEmailStore.setState({ emails: [email], mailboxes: [inbox], selectedEmailKeys: new Set(['e1']) } as never); + await expect(useEmailStore.getState().batchArchive(client as never)).resolves.toBe(false); + expect(client.createMailbox).not.toHaveBeenCalled(); + expect(client.batchMoveEmails).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/__tests__/attachment-chips.test.tsx b/components/email/__tests__/attachment-chips.test.tsx index 1ed87c478..bdff69a81 100644 --- a/components/email/__tests__/attachment-chips.test.tsx +++ b/components/email/__tests__/attachment-chips.test.tsx @@ -54,6 +54,18 @@ describe("ListAttachmentChips", () => { expect(load).not.toHaveBeenCalled(); }); + it("draws Gmail's pill with a filled type badge in Mountain View", () => { + const attachments = [att({ name: "invoice.pdf" })]; + const { rerender } = render(); + const chip = screen.getByTitle("invoice.pdf"); + expect(chip.className).toContain("rounded-full"); + expect(chip.querySelector(".bg-red-600 svg")).toBeTruthy(); + + rerender(); + expect(screen.getByTitle("invoice.pdf").className).not.toContain("rounded-full"); + expect(screen.getByTitle("invoice.pdf").querySelector(".bg-red-600")).toBeNull(); + }); + it("does not load for a row without a paperclip", () => { const load = vi.fn(() => () => {}); render(); diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index e7de9971f..4b4c18172 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => { const authState = { client: clientMock, primaryIdentity: { email: 'user@example.com' }, + identities: [{ email: 'user@example.com' }] as Array<{ email: string }>, getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)), }; @@ -190,6 +191,7 @@ describe('CalendarInvitationBanner', () => { mocks.sourceClientMock.fetchBlob.mockReset(); mocks.sourceClientMock.fetchBlob.mockRejectedValue(new Error('no blob')); mocks.authState.getClientForAccount.mockClear(); + mocks.authState.identities = [{ email: 'user@example.com' }]; mocks.emailState.viewingAccountId = null; mocks.emailState.selectedMailbox = 'inbox'; mocks.emailState.mailboxes = []; @@ -548,6 +550,41 @@ describe('CalendarInvitationBanner', () => { expect(await screen.findByText('Response sent')).toBeInTheDocument(); }); + it('finds the user under an alias identity the invite was sent to (#1283)', async () => { + mocks.authState.identities = [{ email: 'user@example.com' }, { email: 'Alias@example.com' }]; + const participants = { + aliasAttendee: { + '@type': 'Participant', + email: 'alias@example.com', + name: 'User', + roles: { attendee: true }, + participationStatus: 'needs-action', + }, + }; + mocks.calendarState.events = [ + { id: 'event-8', uid: 'uid-8', start: '2026-03-30T09:00:00Z', participants }, + ]; + mocks.clientMock.parseCalendarEvents.mockResolvedValue([ + { uid: 'uid-8', title: 'Alias Event', start: '2026-03-30T09:00:00Z', participants }, + ]); + mocks.calendarState.rsvpEvent.mockResolvedValue(true); + + render(); + + fireEvent.click(await screen.findByText('Accept')); + + await waitFor(() => { + expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith( + mocks.clientMock, + 'event-8', + 'aliasAttendee', + 'accepted', + null, + ); + }); + expect(await screen.findByText('Response sent')).toBeInTheDocument(); + }); + it('says nothing was sent when a plugin stops the answer', async () => { mocks.calendarState.events = [ { diff --git a/components/email/__tests__/compose-format.test.ts b/components/email/__tests__/compose-format.test.ts index 53b34f970..939c856c7 100644 --- a/components/email/__tests__/compose-format.test.ts +++ b/components/email/__tests__/compose-format.test.ts @@ -5,6 +5,7 @@ import { plainComposeBodyToHtml, replaceEmbeddedSignature, replacePlainTextSignature, + replacePlainTextSignatureAboveQuote, stripEmbeddedSignature, } from '../compose-format'; import { SIGNATURE_BLOCK_MARKER, SIGNATURE_RANGE_MARKER, containsEmbeddedSignature } from '../signature-block'; @@ -131,6 +132,43 @@ describe('switching identity swaps the signature', () => { it('adds a signature at the end when there is no quote', () => { expect(replaceEmbeddedSignature('

Hi

', embed(other))).toBe(dom(`

Hi

${embed(other)}`)); }); + + it('adds a signature above the quote header when the body had none (#842)', () => { + const html = '

Hi

On Mon, Bob wrote:

original
'; + expect(replaceEmbeddedSignature(html, embed(other), { quoteHeader: 'On Mon, Bob wrote:' })) + .toBe(dom(`

Hi

${embed(other)}

On Mon, Bob wrote:

original
`)); + }); + + it('adds a signature above a forward header that has no quote element', () => { + const html = '

Hi

---------- Forwarded message ----------
From: Bob

original

'; + expect(replaceEmbeddedSignature(html, embed(other), { quoteHeader: '---------- Forwarded message ----------' })) + .toBe(dom(`

Hi

${embed(other)}

---------- Forwarded message ----------
From: Bob

original

`)); + }); + }); + + describe('replacePlainTextSignatureAboveQuote (#842)', () => { + const header = 'On Mon, Bob wrote:'; + const quote = `${header}\n> original`; + + it('adds the signature between the reply and the quote header', () => { + expect(replacePlainTextSignatureAboveQuote(`\n\n${quote}`, header, none, identity, { separator: true })) + .toBe(`\n\n-- \nAlice\nAcme\n\n${quote}`); + expect(replacePlainTextSignatureAboveQuote(`Hi\n\n${quote}`, header, none, identity, { separator: true })) + .toBe(`Hi\n\n-- \nAlice\nAcme\n\n${quote}`); + }); + + it('swaps or removes the signature above the quote', () => { + const signed = `Hi\n\n-- \nAlice\nAcme\n\n${quote}`; + expect(replacePlainTextSignatureAboveQuote(signed, header, identity, other, { separator: true })) + .toBe(`Hi\n\n-- \nTeam\n\n${quote}`); + expect(replacePlainTextSignatureAboveQuote(signed, header, identity, none, { separator: true })) + .toBe(`Hi\n\n${quote}`); + }); + + it('leaves a body alone whose quote header is gone', () => { + expect(replacePlainTextSignatureAboveQuote('Hi\n\n> original', header, none, identity, { separator: true })) + .toBe('Hi\n\n> original'); + }); }); describe('replacePlainTextSignature', () => { diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx index 0f71c836c..50da3398a 100644 --- a/components/email/__tests__/composer-draft-attachments.test.tsx +++ b/components/email/__tests__/composer-draft-attachments.test.tsx @@ -263,7 +263,9 @@ describe('re-opened draft attachments (#849)', () => { expect.objectContaining({ blobId: 'part-blob-v1', name: 'report.pdf', size: 1234 }), ]); // ... after which the blobId is re-resolved against the new version. - expect(getEmail).toHaveBeenCalledWith('draft-v2'); + // Re-resolved in the account the draft was saved in (primary here: the mock + // client has no resolveSendAccountId). + expect(getEmail).toHaveBeenCalledWith('draft-v2', undefined); // The re-resolution itself must not count as a change: no phantom // save loop from the blobId swap. diff --git a/components/email/__tests__/composer-inline-image-cid.test.tsx b/components/email/__tests__/composer-inline-image-cid.test.tsx new file mode 100644 index 000000000..5896ea943 --- /dev/null +++ b/components/email/__tests__/composer-inline-image-cid.test.tsx @@ -0,0 +1,288 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; + +// ─── Heavy component mocks (mirrors composer-draft-attachments.test.tsx) ────── + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }) }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + externalContentPolicy: 'block', + isSenderTrusted: () => false, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', + plainTextBodyHasSignature: () => false, + plainTextBodyWithoutSignature: (body: string) => body, +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +/** + * Senders may declare `Content-ID: ` (angle brackets kept) + * while the body always references it bracket-free as `src="cid:logo123@sender"`. + * The viewers already normalize both sides before matching; the reply/forward + * hydration path didn't, so any such image silently stayed a blank placeholder + * in the composer and was dropped from the outgoing message on send. + */ +describe('reply/forward inline image cid normalization', () => { + afterEach(() => { + useAuthStore.setState({ client: null }); + vi.clearAllMocks(); + }); + + const PNG_MAGIC = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer; + + function mockClient() { + const fetchBlobArrayBuffer = vi.fn().mockResolvedValue(PNG_MAGIC); + useAuthStore.setState({ + client: { + fetchBlobArrayBuffer, + hasDelayedSend: () => false, + supportsPrincipals: () => false, + getMaxDelayedSend: () => 0, + } as never, + }); + return { fetchBlobArrayBuffer }; + } + + it('sends a reply with the image inlined by cid, not duplicated as a regular attachment', async () => { + const { fetchBlobArrayBuffer } = mockClient(); + const onSend = vi.fn(); + + render( + Hi

', + quoteHeaderHtml: 'On X, Sender wrote:', + attachments: [ + { blobId: 'blob-1', name: 'logo.png', type: 'image/png', size: 100, cid: '' }, + ], + }} + onClose={vi.fn()} + /> + ); + + // Hydration: the placeholder gets swapped for the fetched blob's data URL. + await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'image/png')); + + // Reply auto-fills "to" from replyTo.from, so Send is reachable immediately. + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + + const sent = onSend.mock.calls[0][0] as { htmlBody?: string; attachments?: Array<{ cid?: string; blobId: string; disposition?: string }> }; + // The data URL must have been rewritten back to a cid: reference for sending. + expect(sent.htmlBody).toContain('cid:logo123@sender'); + expect(sent.htmlBody).not.toContain('data:image'); + // Exactly one attachment for the one image - a bracket-mismatch used to + // mean it was never matched at all; matching on both regular AND inline + // paths would instead send the same blob twice (see forward test below). + expect(sent.attachments).toEqual([ + expect.objectContaining({ blobId: 'blob-1', cid: 'logo123@sender', disposition: 'inline' }), + ]); + }); + + it('does not duplicate a forwarded inline image (declared octet-stream, no disposition) as a regular attachment', async () => { + const { fetchBlobArrayBuffer } = mockClient(); + const onSend = vi.fn(); + + render( + Hi

', + quoteHeaderHtml: 'Forwarded message', + // Foxmail-style part (#543): no inline disposition, generic type - + // only the cid: body reference marks it as embedded. + attachments: [ + { blobId: 'blob-1', name: 'logo.png', type: 'application/octet-stream', size: 100, cid: '' }, + ], + }} + onClose={vi.fn()} + /> + ); + + await waitFor(() => expect(fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'logo.png', 'application/octet-stream')); + + // The forward attachment-list filter must have recognized blob-1 as + // embedded and kept it out of the regular attachment chips. + expect(screen.queryByText('logo.png')).not.toBeInTheDocument(); + + // Forward doesn't auto-fill "to" - add a recipient so Send is reachable. + // A trailing delimiter is needed: a bare single-address paste is left as + // uncommitted input text rather than immediately chipped. + fireEvent.paste(screen.getByPlaceholderText('to_placeholder'), { + clipboardData: { getData: () => 'bob@example.com;' }, + }); + + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + + const sent = onSend.mock.calls[0][0] as { attachments?: Array<{ cid?: string; blobId: string }> }; + // Same blobId must appear exactly once, as the re-typed inline part - + // never also as a plain attachment (that would send it twice). + expect(sent.attachments).toEqual([ + expect.objectContaining({ blobId: 'blob-1', cid: 'logo123@sender' }), + ]); + }); +}); diff --git a/components/email/__tests__/composer-island-sanitize.test.ts b/components/email/__tests__/composer-island-sanitize.test.ts new file mode 100644 index 000000000..e297be0af --- /dev/null +++ b/components/email/__tests__/composer-island-sanitize.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Editor } from '@tiptap/core'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import type { Editor as ReactEditor } from '@tiptap/react'; + +import { QuotedHtml, serializeEditorContent } from '../quoted-html'; +import { SignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block'; + +// A re-opened draft hands its stored HTML to the editor as is, and the quote +// and signature islands copy their inner HTML verbatim into the app document. +// A draft written by another client, or by another member of a shared +// mailbox, could carry event handlers that then ran in the webmail origin. + +const PAYLOAD = 'l

Quoted original

'; + +function mount(html: string): Editor { + return new Editor({ extensions: [Document, Paragraph, Text, QuotedHtml, SignatureBlock], content: html }); +} + +function islandHtml(editor: Editor, selector: string): string { + const host = editor.view.dom.querySelector(selector) as HTMLElement; + return host.shadowRoot!.innerHTML; +} + +function expectInert(html: string) { + expect(html).not.toMatch(/onerror/i); + expect(html).not.toMatch(/javascript:/i); + expect(html).not.toMatch(/', text: '', attachments: [], handledBy: 'pgp', + })) as never); + + const { container } = renderThread(); + + await waitFor(() => { + const srcdocs = [...container.querySelectorAll('iframe')].map((f) => f.getAttribute('srcdoc') ?? ''); + expect(srcdocs).toHaveLength(2); + for (const doc of srcdocs) { + expect(doc).toContain('Decrypted html body'); + expect(doc).not.toContain('alert(1)'); + } + }); + }, 30_000); + + it('keeps the original body when no plugin handles the message', async () => { + renderHooks.onRenderEmailBody.register('pgp', (async (body: unknown) => body) as never); + + renderThread(); + await act(async () => {}); + + expect(screen.getAllByText(/BEGIN PGP MESSAGE/).length).toBe(2); + }, 30_000); + + it('decrypts again when a plugin asks for a re-render', async () => { + let unlocked = false; + renderHooks.onRenderEmailBody.register('pgp', (async () => (unlocked + ? { html: '', text: 'Unlocked body', attachments: [], handledBy: 'pgp' } + : { html: '', text: '', attachments: [] })) as never); + + renderThread(); + await act(async () => {}); + expect(screen.queryByText('Unlocked body')).toBeNull(); + + unlocked = true; + act(() => { window.dispatchEvent(new Event('plugin:rerender-email')); }); + expect((await screen.findAllByText('Unlocked body')).length).toBe(2); + }, 30_000); +}); diff --git a/components/email/__tests__/thread-list-item-account-dot.test.tsx b/components/email/__tests__/thread-list-item-account-dot.test.tsx new file mode 100644 index 000000000..482048d1f --- /dev/null +++ b/components/email/__tests__/thread-list-item-account-dot.test.tsx @@ -0,0 +1,110 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { + useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + }; +}); + +const ACCOUNT_TEAL = { id: 'acct-teal', avatarColor: '#0d9488', label: 'Teal account' }; +const accountState = { + getAccountById: (id: string) => (id === ACCOUNT_TEAL.id ? ACCOUNT_TEAL : undefined), +}; +vi.mock('@/stores/account-store', () => ({ + useAccountStore: Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector: any) => (selector ? selector(accountState) : accountState), + { getState: () => accountState }, + ), +})); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + hasAttachment: false, + accountId: ACCOUNT_TEAL.id, + accountLabel: ACCOUNT_TEAL.label, + ...overrides, +}); + +function renderEmails(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} onEmailSelect={() => {}} />, + ); +} + +const single = () => [makeEmail()]; +const collapsedThread = () => [ + makeEmail({ id: 'e1', threadId: 't1' }), + makeEmail({ id: 'e2', threadId: 't1', receivedAt: '2024-01-16T10:00:00Z' }), +]; + +// The dot is the only element painted in the account colour. +const accountDot = (c: HTMLElement) => + Array.from(c.querySelectorAll('span.rounded-full')) + .find((el) => el.style.backgroundColor === 'rgb(13, 148, 136)') ?? null; + +describe('ThreadListItem account dot (#912)', () => { + beforeEach(() => { + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + interfaceLayout: 'default', + tintListRowsByAccount: false, + showAccountDot: true, + }); + useEmailStore.setState({ + selectedEmailKeys: new Set(), + selectedMailbox: 'inbox', + isUnifiedView: true, + }); + }); + + it('is on by default', () => { + expect(useSettingsStore.getInitialState().showAccountDot).toBe(true); + }); + + describe.each([ + ['single message', single], + ['collapsed thread', collapsedThread], + ])('%s', (_name, emails) => { + describe.each(['split', 'focus'] as const)('%s layout', (layout) => { + beforeEach(() => { + useSettingsStore.setState({ mailLayout: layout }); + }); + + it('shows the dot in the unified view', () => { + const { container } = renderEmails(emails()); + expect(accountDot(container)).not.toBeNull(); + }); + + it('hides the dot when the setting is off', () => { + useSettingsStore.setState({ showAccountDot: false }); + const { container } = renderEmails(emails()); + expect(accountDot(container)).toBeNull(); + // The rest of the row still renders. + expect(container.textContent).toContain('Test Subject'); + }); + }); + }); +}); diff --git a/components/email/__tests__/thread-list-item-columns.test.tsx b/components/email/__tests__/thread-list-item-columns.test.tsx new file mode 100644 index 000000000..d745c0b27 --- /dev/null +++ b/components/email/__tests__/thread-list-item-columns.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { ListColumnsHeader } from '../list-columns'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { + useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + }; +}); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 2048, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + preview: 'Preview text', + hasAttachment: false, + ...overrides, +}); + +const conversation = () => [ + makeEmail({ id: 'm1', size: 1024, receivedAt: '2024-01-14T10:00:00Z', from: [{ name: 'Alice', email: 'alice@example.com' }] }), + makeEmail({ id: 'm2', size: 3072, receivedAt: '2024-01-15T10:00:00Z', from: [{ name: 'Bob', email: 'bob@example.com' }] }), +]; + +function renderEmails(emails: Email[], isExpanded = false) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} + onEmailSelect={() => {}} + />, + ); +} + +const sizeCells = (c: HTMLElement) => Array.from(c.querySelectorAll('[data-list-size]')).map((el) => el.textContent); + +describe('message list rows under the bottom reading pane (#262)', () => { + beforeEach(() => { + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: true, + mailLayout: 'horizontal', + interfaceLayout: 'default', + density: 'regular', + listColumns: false, + }); + useEmailStore.setState({ + selectedEmailKeys: new Set(), + selectedMailbox: 'inbox', + isUnifiedView: false, + }); + }); + + it('draws a message on one line', () => { + const { container } = renderEmails([makeEmail()]); + // Stacked rows give the preview a paragraph of its own. + expect(container.querySelector('p')).toBeNull(); + expect(screen.getByText(/Preview text/)).toBeInTheDocument(); + }); + + it('keeps stacked rows on a tablet, which shows the split layout', () => { + useUIStore.setState({ isMobile: false, isTablet: true, isDesktop: false }); + const { container } = renderEmails([makeEmail()]); + expect(container.querySelector('p')).not.toBeNull(); + }); + + it('keeps stacked rows in the split layout', () => { + useSettingsStore.setState({ mailLayout: 'split' }); + const { container } = renderEmails([makeEmail()]); + expect(container.querySelector('p')).not.toBeNull(); + }); + + describe('as columns', () => { + beforeEach(() => { + useSettingsStore.setState({ listColumns: true }); + }); + + it('is off by default', () => { + expect(useSettingsStore.getInitialState().listColumns).toBe(false); + }); + + it('shows the size of a message', () => { + const { container } = renderEmails([makeEmail()]); + expect(sizeCells(container)).toEqual(['2 KB']); + }); + + it('shows a folded conversation as one row with names, count and total size', () => { + const { container } = renderEmails(conversation()); + const from = container.querySelector('[data-list-from]') as HTMLElement; + expect(from).not.toBeNull(); + expect(from.textContent).toContain('Alice'); + expect(from.textContent).toContain('Bob'); + expect(within(from).getByText('(2)')).toBeInTheDocument(); + expect(sizeCells(container)).toEqual(['4 KB']); + expect(screen.queryAllByTestId('thread-email-row')).toHaveLength(0); + }); + + it('lists each message of an unfolded conversation under it with its own sender and size', () => { + const { container } = renderEmails(conversation(), true); + const rows = screen.getAllByTestId('thread-email-row'); + expect(rows).toHaveLength(2); + const senders = rows.map((row) => row.textContent); + expect(senders.some((s) => s?.includes('Alice') && !s.includes('Bob'))).toBe(true); + expect(senders.some((s) => s?.includes('Bob') && !s.includes('Alice'))).toBe(true); + expect(sizeCells(container).sort()).toEqual(['1 KB', '3 KB', '4 KB']); + }); + + it('leaves the split layout alone', () => { + useSettingsStore.setState({ mailLayout: 'split' }); + const { container } = renderEmails([makeEmail()]); + expect(sizeCells(container)).toEqual([]); + }); + + it('leaves Mountain View rows alone', () => { + useSettingsStore.setState({ interfaceLayout: 'mountain-view' }); + const { container } = renderEmails([makeEmail()]); + expect(sizeCells(container)).toEqual([]); + }); + + it('also applies to the focused list', () => { + useSettingsStore.setState({ mailLayout: 'focus' }); + const { container } = renderEmails([makeEmail()]); + expect(sizeCells(container)).toEqual(['2 KB']); + }); + }); +}); + +describe('ListColumnsHeader', () => { + it('titles the columns', () => { + render(); + const header = screen.getByTestId('list-columns-header'); + expect(header.textContent).toBe('fromsubjectdatesize'); + }); + + it('heads the first column with the recipient in Sent and Drafts', () => { + render(); + expect(screen.getByTestId('list-columns-header').textContent).toBe('tosubjectdatesize'); + }); +}); diff --git a/components/email/__tests__/thread-list-item-source-folder.test.tsx b/components/email/__tests__/thread-list-item-source-folder.test.tsx new file mode 100644 index 000000000..672aad8da --- /dev/null +++ b/components/email/__tests__/thread-list-item-source-folder.test.tsx @@ -0,0 +1,144 @@ +import { Profiler } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { SEARCH_SCOPE_ALL_FOLDERS } from '@/lib/search-scope-folders'; +import type { Email } from '@/lib/jmap/types'; + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { + useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + }; +}); + +const accountState = { getAccountById: () => undefined }; +vi.mock('@/stores/account-store', () => ({ + useAccountStore: Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector: any) => (selector ? selector(accountState) : accountState), + { getState: () => accountState }, + ), +})); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { archive: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Invoice', + hasAttachment: false, + // Stamped by the all-folders search fan-out (searchAcrossAccounts). + sourceFolder: 'Archive', + ...overrides, +}); + +function renderThread(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} + onEmailSelect={() => {}} + />, + ); +} + +const noop = () => {}; +const single = () => [makeEmail()]; +const conversation = () => [ + makeEmail({ id: 'email-1', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'email-2', receivedAt: '2024-01-16T10:00:00Z' }), +]; + +describe('ThreadListItem folder chip in search results (#1172)', () => { + beforeEach(() => { + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + interfaceLayout: 'default', + }); + useEmailStore.setState({ + selectedEmailKeys: new Set(), + selectedMailbox: 'inbox', + isUnifiedView: false, + selectedKeyword: null, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchMailboxId: '', + }); + }); + + describe.each([ + ['single message', single], + ['conversation', conversation], + ])('%s row', (_label, emails) => { + it('shows the folder when the search covers all folders except Spam and Trash', () => { + useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' }); + renderThread(emails()); + expect(screen.getByTitle('Archive')).toBeTruthy(); + }); + + it('shows the folder when the search covers all folders', () => { + useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: SEARCH_SCOPE_ALL_FOLDERS }); + renderThread(emails()); + expect(screen.getByTitle('Archive')).toBeTruthy(); + }); + + it('shows the folder when only advanced filters drive the search', () => { + useEmailStore.setState({ + searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'alice' }, + searchMailboxId: '', + }); + renderThread(emails()); + expect(screen.getByTitle('Archive')).toBeTruthy(); + }); + + it('hides the folder when the search is scoped to one folder', () => { + useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: 'archive' }); + renderThread(emails()); + expect(screen.queryByTitle('Archive')).toBeNull(); + }); + + it('hides the folder outside a search', () => { + renderThread(emails()); + expect(screen.queryByTitle('Archive')).toBeNull(); + }); + + it('still shows the folder in the unified view', () => { + useEmailStore.setState({ isUnifiedView: true }); + renderThread(emails()); + expect(screen.getByTitle('Archive')).toBeTruthy(); + }); + + it('does not re-render a row without a folder when a search starts', () => { + // The folder's own list stays on screen until the results arrive; its + // rows carry no sourceFolder and have no chip to show. + const [thread] = groupEmailsByThread(emails().map((e) => ({ ...e, sourceFolder: undefined }))); + const onRender = vi.fn(); + render( + + + , + ); + onRender.mockClear(); + act(() => useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' })); + expect(onRender).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 90d21f827..99156b232 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -327,10 +327,109 @@ describe('ThreadListItem keeping the selection when a message opens', () => { expect(onEmailSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'newer' })); expect([...useEmailStore.getState().selectedEmailKeys]).toEqual(['other']); }); +}); - it('still clears them in the default layout', () => { - open('default', [makeEmail()]); - expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0); +// #1014: once something is ticked, a click anywhere on the row ticks it +// instead of opening the message and dropping the selection. +describe('ThreadListItem whole row toggles in selection mode', () => { + const conversation = () => [ + makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }), + makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }), + ]; + + function setup( + emails: Email[], + { layout = 'default', mobile = false, selected = ['other'], isExpanded = false }: { + layout?: 'mountain-view' | 'default'; mobile?: boolean; selected?: string[]; isExpanded?: boolean; + } = {}, + ) { + useSettingsStore.setState({ interfaceLayout: layout, mailLayout: 'split', density: 'regular' } as never); + useUIStore.setState({ isMobile: mobile, isTablet: false, isDesktop: !mobile }); + useEmailStore.setState({ selectedEmailKeys: new Set(selected), selectedMailbox: 'inbox' }); + const onEmailSelect = vi.fn(); + const onEmailDoubleClick = vi.fn(); + const onOpenConversation = vi.fn(); + const onToggleExpand = vi.fn(); + const [thread] = groupEmailsByThread(emails); + const utils = render( + , + ); + return { ...utils, onEmailSelect, onEmailDoubleClick, onOpenConversation, onToggleExpand }; + } + const selection = () => [...useEmailStore.getState().selectedEmailKeys].sort(); + + it('ticks a message row instead of opening it', () => { + const { onEmailSelect } = setup([makeEmail()]); + fireEvent.click(screen.getByText('Test Subject')); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['email-1', 'other']); + }); + + it('unticks it on the next click', () => { + const { onEmailSelect } = setup([makeEmail()], { selected: ['email-1', 'other'] }); + fireEvent.click(screen.getByText('Test Subject')); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['other']); + }); + + it('opens the message as before when nothing is ticked', () => { + const { onEmailSelect } = setup([makeEmail()], { selected: [] }); + fireEvent.click(screen.getByText('Test Subject')); + expect(onEmailSelect).toHaveBeenCalledTimes(1); + expect(selection()).toEqual([]); + }); + + it('does not open the message on a double click', () => { + const { onEmailDoubleClick } = setup([makeEmail()]); + fireEvent.doubleClick(screen.getByText('Test Subject')); + expect(onEmailDoubleClick).not.toHaveBeenCalled(); + }); + + it('ticks every message of a thread row without unfolding it', () => { + const { onEmailSelect, onToggleExpand } = setup(conversation()); + fireEvent.click(screen.getByText('Test Subject')); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(onToggleExpand).not.toHaveBeenCalled(); + expect(selection()).toEqual(['newer', 'older', 'other']); + }); + + it('still unfolds the thread from its chevron', () => { + const { container, onToggleExpand } = setup(conversation()); + fireEvent.click(container.querySelector('[data-expand-toggle]')!); + expect(onToggleExpand).toHaveBeenCalledTimes(1); + expect(selection()).toEqual(['other']); + }); + + it('ticks a thread on the phone instead of opening the thread view', () => { + const { onOpenConversation, onEmailSelect } = setup(conversation(), { mobile: true }); + fireEvent.click(screen.getByText('Test Subject')); + expect(onOpenConversation).not.toHaveBeenCalled(); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['newer', 'older', 'other']); + }); + + it('ticks a Mountain View phone row', () => { + const { onEmailSelect } = setup([makeEmail()], { layout: 'mountain-view', mobile: true }); + fireEvent.click(screen.getByText('Test Subject')); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection()).toEqual(['email-1', 'other']); + }); + + it('ticks a message inside an unfolded thread', () => { + const { onEmailSelect } = setup(conversation(), { isExpanded: true }); + // The unfolded messages show the sender; the header shows the subject. + const older = screen.getAllByText('Alice').find((el) => el.closest('.ps-12'))!; + fireEvent.click(older); + expect(onEmailSelect).not.toHaveBeenCalled(); + expect(selection().filter((k) => k !== 'other')).toHaveLength(1); }); }); @@ -646,3 +745,67 @@ describe('ThreadListItem prefetch on mouse press', () => { expect(onPrefetch).not.toHaveBeenCalled(); }); }); + +describe('ThreadListItem date (#891)', () => { + // A migrated mailbox: the server stamped every message with the import day, + // but the Date header still carries when the mail was really sent. + const imported = { receivedAt: '2024-01-15T10:00:00Z', sentAt: '2019-03-05T08:30:00Z' }; + + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + interfaceLayout: 'default', + dateFormat: 'full', + }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); + }); + + it('shows the Date header, not the import time, on a single-message row', () => { + const { container } = renderRow(makeEmail(imported)); + expect(container.textContent).toMatch(/2019/); + expect(container.textContent).not.toMatch(/2024/); + }); + + it('shows the Date header of the latest message on a thread row', () => { + const emails = [ + makeEmail({ id: 'a', receivedAt: '2024-01-15T09:00:00Z', sentAt: '2019-03-04T08:30:00Z' }), + makeEmail({ id: 'b', ...imported }), + ]; + const [thread] = groupEmailsByThread(emails); + const { container } = render( + {}} onEmailSelect={() => {}} />, + ); + expect(container.textContent).toMatch(/2019/); + expect(container.textContent).not.toMatch(/2024/); + }); +}); + +describe('ThreadListItem hover actions', () => { + // When the row above is deleted from its hover actions, the next row slides + // under the resting pointer. The browser then reports a pointerover whose + // relatedTarget is the list around the row, and React builds no + // onPointerEnter from that, so the row has to arm its actions on pointerover. + const renderInList = (thread: ReturnType[number]) => + render( +
+ {}} onEmailSelect={() => {}} onDelete={() => {}} /> +
, + ); + + it.each([ + ['a single message', () => groupEmailsByThread([makeEmail()])[0]], + ['a conversation', () => groupEmailsByThread([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-15T11:00:00Z' }), + ])[0]], + ])('mounts them for %s that slides under a resting pointer', (_name, makeThread) => { + const { container } = renderInList(makeThread()); + expect(screen.queryByTestId('hover-action-delete')).toBeNull(); + const row = screen.queryByTestId('email-list-item') ?? container.querySelector('[data-unread]'); + expect(row).not.toBeNull(); + fireEvent.pointerOver(row!, { relatedTarget: screen.getByTestId('list') }); + expect(screen.getByTestId('hover-action-delete')).toBeTruthy(); + }); +}); diff --git a/components/email/__tests__/viewer-more-menu-flyouts.test.tsx b/components/email/__tests__/viewer-more-menu-flyouts.test.tsx new file mode 100644 index 000000000..4ff0aff31 --- /dev/null +++ b/components/email/__tests__/viewer-more-menu-flyouts.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// #1261: the Move, Tag and Rules flyouts of the reader's More menu always +// opened on the menu's start side and ran past the reading pane when there +// was no room there. They now pick the side with room, like a context +// submenu does. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); + +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: () => [], +})); + +vi.mock('../rules-menu', async (importOriginal) => ({ + ...(await importOriginal()), + useRulesAvailability: () => ({ availability: 'available', target: {} }), + RulesPanel: () =>
rules panel
, +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], + to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + preview: 'Hello', + hasAttachment: false, + size: 100, + textBody: [], + htmlBody: [], + attachments: [], + bodyValues: {}, +} as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function rect(left: number, top: number, width: number, height: number): DOMRect { + return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect; +} + +afterEach(() => vi.restoreAllMocks()); + +/** Opens Rules with its entry at `entryLeft` (192 px wide) in a 1280 px window; returns the flyout. */ +function openRules(entryLeft: number, direction = 'ltr'): HTMLElement { + render(); + fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); + const entry = screen.getByTestId('viewer-rules').parentElement!; + // jsdom doesn't inherit `direction` from , so set it where it is read. + entry.style.direction = direction; + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === entry) return rect(entryLeft, 120, 192, 32); + if (this.getAttribute('aria-label') === 'rules.title') return rect(0, 0, 288, 200); + return rect(0, 0, 0, 0); + }); + fireEvent.click(screen.getByTestId('viewer-rules')); + return screen.getByRole('menu', { name: 'rules.title' }); +} + +describe('reader More menu flyouts (#1261)', () => { + it('opens on the right when there is no room on the left', () => { + const flyout = openRules(100); + expect(flyout.style.left).toBe('292px'); + expect(flyout.style.top).toBe('120px'); + }); + + it('still opens on the left when both sides have room', () => { + const flyout = openRules(600); + expect(flyout.style.left).toBe('312px'); + }); + + it('opens on the right when both sides have room in a right-to-left layout', () => { + const flyout = openRules(600, 'rtl'); + expect(flyout.style.left).toBe('792px'); + }); +}); diff --git a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx new file mode 100644 index 000000000..25b0b66be --- /dev/null +++ b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx @@ -0,0 +1,135 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import React, { useEffect } from 'react'; +import { cleanup } from '@testing-library/react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// On a desktop the reader's top toolbar runs over the plugin side panel to +// the right edge; the panel starts below it. On a phone the toolbar stays in +// the message column. Its menus close when a click goes into a frame. The +// panel is the reader's, not the message's: its frame lives on from one +// message to the next, and its toggle sits at the end of the toolbar. + +const device = { isMobile: false }; +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +const mounts: Record = {}; +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: ({ name }: { name: string }) => { + useEffect(() => { + mounts[name] = (mounts[name] ?? 0) + 1; + // Once per mount on purpose: that is what the test counts. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return React.createElement('div', { 'data-slot': name }); + }, +})); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'p', order: 100 }] : []), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [], attachments: [], bodyValues: {}, +} as unknown as Email; + +const show = () => render( + {}} />, +); + +/** The plugin panel, open or folded to its toggle. */ +const sidePanel = () => + (document.querySelector('[data-plugin-detail-panel]') as HTMLElement | null) + ?? (screen.getByLabelText('plugin_panel_show').closest('div') as HTMLElement); + +/** An item of the top toolbar. */ +const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTMLElement; + +afterEach(() => { + cleanup(); + device.isMobile = false; +}); + +describe('the reader toolbar and the plugin side panel', () => { + it('runs the toolbar over the side panel on a desktop', () => { + show(); + const panel = sidePanel(); + const item = toolbarItem(); + expect(item).toBeTruthy(); + // The box that holds the panel does not hold the toolbar... + expect(panel.parentElement!.contains(item)).toBe(false); + // ...and the toolbar comes before it, at the top of the reader. + expect(item.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const root = document.querySelector('[data-tour="email-viewer"]')!; + expect(root.firstElementChild!.contains(item)).toBe(true); + expect(root.firstElementChild!.contains(panel)).toBe(false); + }); + + it('closes the More menu when a click goes into a frame', () => { + show(); + act(() => { fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); }); + expect(screen.getByRole('menu', { name: 'more_actions' })).toBeTruthy(); + // A click in the message body or a plugin panel moves focus into that + // frame: this document sees no mousedown, only the window losing focus. + act(() => { window.dispatchEvent(new Event('blur')); }); + expect(screen.queryByRole('menu', { name: 'more_actions' })).toBeNull(); + }); + + it('keeps the side panel frame while the user reads on, and between messages', () => { + const other = { ...email, id: 'e2', blobId: 'b2', threadId: 't2', subject: 'Second' } as unknown as Email; + const view = (props: { email: Email | null; isLoading: boolean }) => ( + {}} {...props} /> + ); + mounts['email-detail-sidebar'] = 0; + const { rerender } = render(view({ email, isLoading: false })); + expect(mounts['email-detail-sidebar']).toBe(1); + // The next message loading, then open; then none at all. + rerender(view({ email: null, isLoading: true })); + rerender(view({ email: other, isLoading: false })); + rerender(view({ email: null, isLoading: false })); + expect(screen.getByText('no_conversation_selected')).toBeTruthy(); + expect(document.querySelector('[data-slot="email-detail-sidebar"]')).toBeTruthy(); + expect(mounts['email-detail-sidebar']).toBe(1); + }); + + it('folds and unfolds the side panel from the toolbar', () => { + show(); + const toggle = document.querySelector('[data-plugin-panel-toggle]') as HTMLElement; + // At the end of the toolbar, not in the panel. + expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull(); + expect(toggle.getAttribute('aria-label')).toBe('plugin_panel_hide'); + act(() => { fireEvent.click(toggle); }); + expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull(); + // Folded, the panel leaves no strip of its own: the toolbar button brings it back. + expect(screen.getAllByLabelText('plugin_panel_show')).toHaveLength(1); + act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); }); + expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy(); + }); + + it('keeps the toolbar in the message column on a phone', () => { + device.isMobile = true; + show(); + expect(document.querySelector('[data-viewer-top-toolbar="full-width"]')).toBeNull(); + expect(document.querySelector('[data-viewer-top-toolbar="column"]')).toBeTruthy(); + }); +}); diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx index 9aa2a6f8b..c927a493a 100644 --- a/components/email/attachment-chips.tsx +++ b/components/email/attachment-chips.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "@/components/icons"; import { cn } from "@/lib/utils"; +import { stripBidiControls } from "@/lib/bidi"; import type { Attachment, Email } from "@/lib/jmap/types"; import type { LoadListAttachments } from "@/lib/list-attachments"; @@ -20,17 +21,18 @@ export function realAttachments(attachments?: Attachment[]): Attachment[] { ); } -const ICON_BY_TYPE: { match: RegExp; icon: typeof FileIcon; className: string }[] = [ - { match: /^image\//, icon: FileImage, className: "text-violet-600 dark:text-violet-400" }, - { match: /pdf/, icon: FileText, className: "text-red-600 dark:text-red-400" }, - { match: /sheet|excel|csv/, icon: FileSpreadsheet, className: "text-emerald-600 dark:text-emerald-400" }, - { match: /zip|compress|tar|rar|7z/, icon: FileArchive, className: "text-amber-600 dark:text-amber-400" }, - { match: /word|document|rtf|text\//, icon: FileText, className: "text-sky-600 dark:text-sky-400" }, +// `badge` fills the square Mountain View draws behind a white glyph, as Gmail does. +const ICON_BY_TYPE: { match: RegExp; icon: typeof FileIcon; className: string; badge: string }[] = [ + { match: /^image\//, icon: FileImage, className: "text-violet-600 dark:text-violet-400", badge: "bg-red-600" }, + { match: /pdf/, icon: FileText, className: "text-red-600 dark:text-red-400", badge: "bg-red-600" }, + { match: /sheet|excel|csv/, icon: FileSpreadsheet, className: "text-emerald-600 dark:text-emerald-400", badge: "bg-emerald-600" }, + { match: /zip|compress|tar|rar|7z/, icon: FileArchive, className: "text-amber-600 dark:text-amber-400", badge: "bg-zinc-500" }, + { match: /word|document|rtf|text\//, icon: FileText, className: "text-sky-600 dark:text-sky-400", badge: "bg-blue-600" }, ]; function iconFor(type: string, name: string) { const probe = `${type || ""} ${name || ""}`.toLowerCase(); - return ICON_BY_TYPE.find((e) => e.match.test(probe)) ?? { icon: FileIcon, className: "text-muted-foreground" }; + return ICON_BY_TYPE.find((e) => e.match.test(probe)) ?? { icon: FileIcon, className: "text-muted-foreground", badge: "bg-zinc-500" }; } /** Long names are unreadable truncated at the tail — the extension is the @@ -48,6 +50,13 @@ function shortName(name: string, max = 18): string { export const CHIP_BOX_CLASS = "inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-0.5 text-xs"; export const CHIP_ICON_CLASS = "h-3.5 w-3.5 flex-shrink-0"; +// Mountain View's chips are Gmail's: a pill with a filled square in front. +export const MV_CHIP_BOX_CLASS = "inline-flex h-8 items-center gap-2 rounded-full border border-border ps-3 pe-4 text-sm"; +export const MV_CHIP_BADGE_CLASS = "inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[3px] text-white"; +export const MV_CHIP_BADGE_ICON_CLASS = "h-3 w-3"; + +export type ChipVariant = "default" | "mountain-view"; + interface AttachmentChipsProps { attachments?: Attachment[]; onOpen: (attachment: Attachment) => void; @@ -55,10 +64,12 @@ interface AttachmentChipsProps { max?: number; /** A chip drawn ahead of the attachments in the same row (the verification code). */ leading?: ReactNode; + variant?: ChipVariant; className?: string; } -export function AttachmentChips({ attachments, onOpen, max = 2, leading, className }: AttachmentChipsProps) { +export function AttachmentChips({ attachments, onOpen, max = 2, leading, variant = "default", className }: AttachmentChipsProps) { + const mv = variant === "mountain-view"; const real = realAttachments(attachments); if (!real.length && !leading) return null; @@ -67,10 +78,10 @@ export function AttachmentChips({ attachments, onOpen, max = 2, leading, classNa const overflow = real.length - shown.length; return ( -
+
{leading} {shown.map((a) => { - const { icon: Icon, className: iconClass } = iconFor(a.type, a.name ?? ""); + const { icon: Icon, className: iconClass, badge } = iconFor(a.type, a.name ?? ""); return ( ); })} {overflow > 0 && ( - + +{overflow} )} @@ -171,8 +193,8 @@ export function ListAttachmentChips({ email, load, ...chipProps }: ListAttachmen } return (
- - + +  
diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index f6e714145..806020b2a 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -33,14 +33,17 @@ import { getInvitationActorSummary, getInvitationTrustAssessment, formatEventSummary, - findParticipantByEmail, + formatInvitationSchedule, + findParticipantByEmails, extractMethodFromRawIcs, + type EventSummary, type InvitationMethod, type InvitationTrustAssessment, } from '@/lib/calendar-invitation'; import { participantsAskedAgain } from '@/lib/calendar-participants'; import { isValidTimeZone, moveLocalDateTime } from '@/lib/timezone'; import { cn } from '@/lib/utils'; +import { getEventStartDate } from '@/lib/calendar-utils'; import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message'; @@ -288,7 +291,7 @@ function buildInvitationChangeItems( t: ReturnType, currentEvent: Partial | null, proposedEvent: Partial | null, - formatDateTime: (dateStr: string | null) => string, + formatSchedule: (summary: EventSummary) => string, ): InvitationChangeItem[] { if (!currentEvent || !proposedEvent) { return []; @@ -307,10 +310,10 @@ function buildInvitationChangeItems( } const currentSchedule = currentSummary.start - ? `${formatDateTime(currentSummary.start)}${currentSummary.end ? ` - ${formatDateTime(currentSummary.end)}` : ''}` + ? formatSchedule(currentSummary) : t('change_empty'); const proposedSchedule = proposedSummary.start - ? `${formatDateTime(proposedSummary.start)}${proposedSummary.end ? ` - ${formatDateTime(proposedSummary.end)}` : ''}` + ? formatSchedule(proposedSummary) : t('change_empty'); if (currentSchedule !== proposedSchedule && proposedSummary.start) { changes.push({ @@ -416,7 +419,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const viewedMailbox = viewMailboxes.find((m) => m.id === selectedMailboxId); const sharedOwnerAccountId = viewedMailbox?.isShared ? viewedMailbox.accountId : undefined; const blobAccountId = email.sourceAccountId ?? sharedOwnerAccountId ?? parseClient?.getAccountId(); - const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email); + // Every address the user sends as, primary first: an invite may be + // addressed to an alias identity (#1283). + const identityEmails = useAuthStore(useShallow((s) => { + const emails = [s.primaryIdentity?.email, ...s.identities.map((identity) => identity.email)] + .filter((address): address is string => Boolean(address)) + .map((address) => address.toLowerCase()); + return Array.from(new Set(emails)); + })); const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const timeFormat = useSettingsStore((s) => s.timeFormat); const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ @@ -550,12 +560,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ? storeEvents.find((e) => e.uid === parsedEvent.uid) : null; - const currentUserParticipant = existingEvent && currentUserEmail - ? findParticipantByEmail(existingEvent, currentUserEmail) + const currentUserParticipant = existingEvent + ? findParticipantByEmails(existingEvent, identityEmails) : null; - const fallbackParsedParticipant = !currentUserParticipant && parsedEvent && currentUserEmail - ? findParticipantByEmail(parsedEvent, currentUserEmail) + const fallbackParsedParticipant = !currentUserParticipant && parsedEvent + ? findParticipantByEmails(parsedEvent, identityEmails) : null; const participantForUser = currentUserParticipant || fallbackParsedParticipant; @@ -571,9 +581,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp || myParticipant?.participant.participationStatus || null; const userIsOrganizer = Boolean(isOnlyOrganizer); - const userIsSender = Boolean( - currentUserEmail && email.from?.[0]?.email?.toLowerCase() === currentUserEmail.toLowerCase() - ); + const senderAddress = email.from?.[0]?.email?.toLowerCase(); + const userIsSender = Boolean(senderAddress && identityEmails.includes(senderAddress)); const bannerTitle = getBannerTitle(t, method); const bannerInfo = getBannerInfo(t, method, userIsOrganizer, supportsCalendar); const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method) : null; @@ -630,8 +639,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const eventForRsvp = existingEvent ? await resolveExistingEventForRsvp() : null; - const existingEventParticipant = eventForRsvp && currentUserEmail - ? findParticipantByEmail(eventForRsvp, currentUserEmail) + const existingEventParticipant = eventForRsvp + ? findParticipantByEmails(eventForRsvp, identityEmails) : null; const canFallbackToParsedParticipant = Boolean( existingEvent @@ -676,7 +685,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp (e) => e.uid === parsedEvent.uid ); const participant = myParticipant - || (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null); + || (newEvent ? findParticipantByEmails(newEvent, identityEmails) : null); if (newEvent && participant) { if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return; setRsvpStatus(status); @@ -700,13 +709,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }; const handleViewInCalendar = () => { - const targetDate = existingEvent?.utcStart - || existingEvent?.start - || parsedEvent?.utcStart - || parsedEvent?.start; - - if (targetDate) { - const parsedDate = new Date(targetDate); + const target = existingEvent?.start ? existingEvent : parsedEvent; + if (target?.start) { + const parsedDate = getEventStartDate({ + start: target.start, + utcStart: target.utcStart ?? null, + showWithoutTime: target.showWithoutTime ?? false, + timeZone: target.timeZone, + }); if (!Number.isNaN(parsedDate.getTime())) { setSelectedDate(parsedDate); } @@ -741,31 +751,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; - const isAllDayEvent = parsedEvent?.showWithoutTime ?? false; - - const formatDateTime = (dateStr: string | null) => { - if (!dateStr) return ''; - const date = new Date(dateStr); - if (isNaN(date.getTime())) return dateStr; - if (isAllDayEvent) { - return format.dateTime(date, { - weekday: 'short', - month: 'short', - day: 'numeric', - }); - } - return format.dateTime(date, { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: timeFormat === '12h', - }); - }; + const formatSchedule = (eventSummary: EventSummary) => formatInvitationSchedule( + eventSummary, + (date, options) => format.dateTime(date, options), + timeFormat === '12h', + ); const proposedChanges = method === 'counter' && proposalTarget && parsedEvent - ? buildInvitationChangeItems(t, proposalTarget, parsedEvent, formatDateTime) + ? buildInvitationChangeItems(t, proposalTarget, parsedEvent, formatSchedule) : []; const canApplyProposal = Boolean( client @@ -924,8 +917,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp - {formatDateTime(summary.start)} - {summary.end && ` – ${formatDateTime(summary.end)}`} + {formatSchedule(summary)} )} diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts index 4cd9987a0..82e9e9081 100644 --- a/components/email/compose-format.ts +++ b/components/email/compose-format.ts @@ -120,15 +120,25 @@ export function stripEmbeddedSignature(html: string): { html: string; hadSignatu * The HTML body with its embedded signature swapped for `signatureHtml` (from * buildEmbeddedSignatureHtml; empty for an identity without a signature). * The new signature goes where the old one was. A body without one - the - * previous identity had none - gets it before the quote, or at the end when - * there is no quote, which is where getInitialBody would have put it. + * previous identity had none - gets it where getInitialBody would have put + * it: above the quote header of a reply or forward (`quoteHeader`, the + * header's first line, e.g. "On ... wrote:"), else before the quote, else at + * the end. */ -export function replaceEmbeddedSignature(html: string, signatureHtml: string): string { +export function replaceEmbeddedSignature( + html: string, + signatureHtml: string, + options: { quoteHeader?: string } = {}, +): string { const doc = new DOMParser().parseFromString(html, 'text/html'); let removed = removeSignatureRange(doc); if (!removed) { if (!signatureHtml) return html; - const quote = Array.from(doc.body.childNodes).find(isQuoteBoundary) ?? null; + const children = Array.from(doc.body.childNodes); + const header = options.quoteHeader + ? children.find((node) => node.textContent?.includes(options.quoteHeader!)) + : undefined; + const quote = header ?? children.find(isQuoteBoundary) ?? null; removed = { parent: doc.body, insertBefore: quote }; } const host = doc.createElement('div'); @@ -157,6 +167,25 @@ export function replacePlainTextSignature( return appendPlainTextSignature(bare, next, options); } +/** + * replacePlainTextSignature for a plain-text reply or forward with the + * signature above the quote: the signature ends the part before the quote + * header (`quoteHeader`, its first line). A body whose header can't be found + * is returned unchanged. + */ +export function replacePlainTextSignatureAboveQuote( + body: string, + quoteHeader: string, + previous: SignatureIdentityLike, + next: SignatureIdentityLike, + options: { separator: boolean }, +): string { + const at = quoteHeader ? body.indexOf(quoteHeader) : -1; + if (at < 0) return body; + const head = body.slice(0, at).replace(/\n+$/, ''); + return `${replacePlainTextSignature(head, previous, next, options)}\n\n${body.slice(at)}`; +} + /** * Convert the rich-text editor's body to a plain-text body. Formatting is * dropped (that is the point of plain text); an embedded signature comes back diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx index c2fa7860a..25360fd45 100644 --- a/components/email/composer-plugin-sidebars.tsx +++ b/components/email/composer-plugin-sidebars.tsx @@ -1,22 +1,29 @@ "use client"; -// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a -// fixed width and a toggle to fold it to a thin strip. When the composer is -// too narrow for an open panel next to it, the panel folds by itself (right -// first); opening it anyway lays it over the composer instead of squeezing -// the message into a column nobody can write in. +// The composer-sidebar and composer-sidebar-right plugin panels. Each has a +// width the user can drag (kept across composers) and a toggle to fold it +// to a thin strip. When the composer is too narrow for an open panel next +// to it, the panel folds by itself (right first); opening it anyway lays it +// over the composer instead of squeezing the message into a column nobody +// can write in. -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; +import { onPanelRequest, type PanelRequest } from "@/lib/plugin-sandbox/panel-requests"; +import { ResizeHandle } from "@/components/layout/resize-handle"; import { cn } from "@/lib/utils"; -/** Width of an open panel. */ +/** Width of an open panel until the user drags it. */ export const COMPOSER_SIDEBAR_WIDTH = 260; +export const MIN_COMPOSER_SIDEBAR_WIDTH = 220; +export const MAX_COMPOSER_SIDEBAR_WIDTH = 480; /** Width of a folded panel (the strip with its toggle). */ export const COMPOSER_SIDEBAR_STRIP = 37; +/** Below this window width the panels are not shown at all (the `md` breakpoint). */ +export const COMPOSER_SIDEBAR_MIN_WINDOW = 768; /** * Narrowest the composer itself may get next to open panels: below this its * bottom bar (Attach, Templates, Options, Discard, Send) runs into itself. @@ -33,6 +40,8 @@ interface SideState { collapsed: boolean; /** Opened by the user while there was no room for it. */ overlayOpen?: boolean; + /** The user's width for it; the default when not dragged yet. */ + width?: number; } /** @@ -50,7 +59,7 @@ export function composerSidebarModes( const place = (side: SideState): ComposerSidebarMode | null => { if (!side.present) return null; if (side.collapsed) return "folded"; - const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP; + const extra = (side.width ?? COMPOSER_SIDEBAR_WIDTH) - COMPOSER_SIDEBAR_STRIP; if (extra <= room) { room -= extra; return "docked"; @@ -62,22 +71,52 @@ export function composerSidebarModes( } const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`; +const widthKey = (side: ComposerSidebarSide) => `composerPluginSidebarWidth:${side}`; -function readCollapsed(side: ComposerSidebarSide): boolean { - if (typeof window === "undefined") return false; - try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; } +/** The user's choice for a side; null until they fold or open it once. */ +function readCollapsed(side: ComposerSidebarSide): boolean | null { + if (typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(storageKey(side)); + return stored === "1" ? true : stored === "0" ? false : null; + } catch { return null; } } -/** Layout state of both panels for a composer whose root element is `root`. */ -export function useComposerSidebars(root: HTMLElement | null) { - const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0; - const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0; +export function clampSidebarWidth(width: number): number { + return Math.max(MIN_COMPOSER_SIDEBAR_WIDTH, Math.min(MAX_COMPOSER_SIDEBAR_WIDTH, Math.round(width))); +} + +function readWidth(side: ComposerSidebarSide): number { + if (typeof window === "undefined") return COMPOSER_SIDEBAR_WIDTH; + try { + const n = parseInt(localStorage.getItem(widthKey(side)) ?? "", 10); + return Number.isFinite(n) ? clampSidebarWidth(n) : COMPOSER_SIDEBAR_WIDTH; + } catch { return COMPOSER_SIDEBAR_WIDTH; } +} + +/** + * Layout state of both panels for a composer whose root element is `root`. + * `composerId` is the id plugins know this composer by; their + * api.ui.openPanel requests for it are answered here. + */ +export function useComposerSidebars(root: HTMLElement | null, composerId: string | null = null) { + const leftOffers = usePluginSlotOffers("composer-sidebar"); + const rightOffers = usePluginSlotOffers("composer-sidebar-right"); + const hasLeft = leftOffers.length > 0; + const hasRight = rightOffers.length > 0; const [width, setWidth] = useState(0); - const [collapsed, setCollapsed] = useState>(() => ({ + // The user's choice per side; until they make one, a panel whose plugin + // asked to start folded (a chat opened on demand) is folded, the rest open. + const [chosen, setChosen] = useState>(() => ({ left: readCollapsed("left"), right: readCollapsed("right"), })); + const collapsed: Record = { + left: chosen.left ?? leftOffers.some((o) => o.startFolded), + right: chosen.right ?? rightOffers.some((o) => o.startFolded), + }; const [overlayOpen, setOverlayOpen] = useState>({ left: false, right: false }); + const [widths, setWidths] = useState>(() => ({ left: readWidth("left"), right: readWidth("right") })); useEffect(() => { if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return; @@ -90,12 +129,19 @@ export function useComposerSidebars(root: HTMLElement | null) { const modes = composerSidebarModes( width, - { present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left }, - { present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right }, + { present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left, width: widths.left }, + { present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right, width: widths.right }, ); + /** The user dragged a panel's edge. */ + const setSideWidth = (side: ComposerSidebarSide, value: number) => { + const next = clampSidebarWidth(value); + setWidths((prev) => (prev[side] === next ? prev : { ...prev, [side]: next })); + try { localStorage.setItem(widthKey(side), String(next)); } catch { /* storage blocked */ } + }; + const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => { - setCollapsed((prev) => ({ ...prev, [side]: value })); + setChosen((prev) => ({ ...prev, [side]: value })); try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ } }; @@ -104,8 +150,8 @@ export function useComposerSidebars(root: HTMLElement | null) { const next = { ...collapsed, [side]: false }; const fits = composerSidebarModes( width, - { present: hasLeft, collapsed: next.left }, - { present: hasRight, collapsed: next.right }, + { present: hasLeft, collapsed: next.left, width: widths.left }, + { present: hasRight, collapsed: next.right, width: widths.right }, )[side] === "docked"; setSideCollapsed(side, false); setOverlayOpen((prev) => ({ ...prev, [side]: !fits })); @@ -115,7 +161,32 @@ export function useComposerSidebars(root: HTMLElement | null) { setOverlayOpen((prev) => ({ ...prev, [side]: false })); }; - return { modes, open, close }; + // Plugins opening their panel (api.ui.openPanel): counts up per side when + // it opens, and reaches the slot as `focusRequest`. + const [focusRequests, setFocusRequests] = useState>({ left: 0, right: 0 }); + const latest = useRef({ modes, open, close, composerId, hasLeft, hasRight }); + latest.current = { modes, open, close, composerId, hasLeft, hasRight }; + useEffect(() => onPanelRequest((request: PanelRequest) => { + const side: ComposerSidebarSide | null = request.slot === "composer-sidebar" ? "left" : request.slot === "composer-sidebar-right" ? "right" : null; + if (!side) return null; + const state = latest.current; + if (request.composerId && request.composerId !== state.composerId) return null; + if (!(side === "left" ? state.hasLeft : state.hasRight)) return null; + // On a phone the panels are not on screen (hidden below md): the plugin + // then falls back to a dialog. + if (typeof window !== "undefined" && window.innerWidth < COMPOSER_SIDEBAR_MIN_WINDOW) return null; + const isOpen = state.modes[side] === "docked" || state.modes[side] === "overlay"; + const wantOpen = request.mode === "open" ? true : request.mode === "close" ? false : !isOpen; + if (wantOpen) { + if (!isOpen) state.open(side); + setFocusRequests((prev) => ({ ...prev, [side]: prev[side] + 1 })); + } else if (isOpen) { + state.close(side); + } + return wantOpen; + }), []); + + return { modes, open, close, focusRequests, widths, setSideWidth }; } /** One plugin panel of the composer, in the mode useComposerSidebars picked. */ @@ -124,13 +195,22 @@ export function ComposerPluginSidebar({ mode, onOpen, onClose, + extraProps, + width = COMPOSER_SIDEBAR_WIDTH, + onWidthChange, }: { side: ComposerSidebarSide; mode: ComposerSidebarMode | null; onOpen: () => void; onClose: () => void; + /** What the panel's plugins get: the composer id, the draft and the selection (see the slot catalogue). */ + extraProps?: Record; + width?: number; + /** The user drags the panel's edge; without it the panel has no handle. */ + onWidthChange?: (width: number) => void; }) { const t = useTranslations("email_viewer"); + const dragStart = useRef(width); if (!mode) return null; const border = side === "left" ? "border-e" : "border-s"; const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen; @@ -157,14 +237,28 @@ export function ComposerPluginSidebar({ ); } + // Docked beside the composer: a handle on the panel's inner edge. Dragging + // towards the composer widens the panel (the delta flips for the right one). + const handle = mode === "docked" && onWidthChange ? ( + { dragStart.current = width; }} + onResize={(delta) => onWidthChange(dragStart.current + (side === "left" ? delta : -delta))} + onResizeEnd={() => { dragStart.current = width; }} + onDoubleClick={() => onWidthChange(COMPOSER_SIDEBAR_WIDTH)} + /> + ) : null; return ( + <> + {side === "right" && handle}
@@ -181,9 +275,11 @@ export function ComposerPluginSidebar({
{side === "left" - ? - : } + ? + : }
+ {side === "left" && handle} + ); } diff --git a/components/email/composer-selection-menu.tsx b/components/email/composer-selection-menu.tsx new file mode 100644 index 000000000..ea17d8e74 --- /dev/null +++ b/components/email/composer-selection-menu.tsx @@ -0,0 +1,90 @@ +"use client"; + +// The right-click menu on selected text in the composer. It only appears +// when a plugin declared entries for it (the composer-selection-menu +// surface, api.ui.setActions); otherwise the browser's own menu stays, with +// its spelling suggestions. Cut and Copy are kept, since the menu replaces +// the one that had them. A click on a plugin entry carries the composer id +// and the selected text. + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { Copy, Scissors } from "@/components/icons"; + +export const COMPOSER_SELECTION_SURFACE = "composer-selection-menu"; + +export interface SelectionMenuTarget { + /** The selected text, as the plugin gets it. */ + text: string; +} + +/** + * Where the menu opens for a contextmenu event. The keyboard (Shift+F10, + * the Menu key) fires it at 0,0: then the menu opens under the selection. + */ +export function selectionMenuPoint( + e: { clientX: number; clientY: number; currentTarget?: EventTarget | null }, + selection: Selection | null = typeof window === "undefined" ? null : window.getSelection(), +): { x: number; y: number } { + if (e.clientX || e.clientY) return { x: e.clientX, y: e.clientY }; + if (selection && selection.rangeCount > 0) { + const rect = selection.getRangeAt(0).getBoundingClientRect(); + if (rect.width || rect.height) return { x: rect.left, y: rect.bottom }; + } + const el = e.currentTarget instanceof Element ? e.currentTarget : null; + const rect = el?.getBoundingClientRect(); + return rect ? { x: rect.left + 16, y: rect.top + 16 } : { x: 0, y: 0 }; +} + +/** The menu's state and the contextmenu handler for the message text. */ +export function useComposerSelectionMenu() { + const actions = usePluginActions(COMPOSER_SELECTION_SURFACE); + const menu = useContextMenu(); + const { openContextMenu } = menu; + const enabled = actions.length > 0; + const onContextMenu = useCallback((e: React.MouseEvent, read: () => string) => { + if (!enabled) return; + const text = read(); + if (!text.trim()) return; + const point = selectionMenuPoint(e); + openContextMenu({ + preventDefault: () => e.preventDefault(), + stopPropagation: () => e.stopPropagation(), + clientX: point.x, + clientY: point.y, + } as React.MouseEvent, { text }); + }, [enabled, openContextMenu]); + return { ...menu, onContextMenu, enabled }; +} + +export function ComposerSelectionMenu({ + menu, + composerId, + onClipboard, +}: { + menu: ReturnType; + composerId: string | null; + /** Cut or copy the selection the menu was opened on. */ + onClipboard: (command: "cut" | "copy") => void; +}) { + const t = useTranslations("email_composer"); + const { contextMenu, closeContextMenu, menuRef } = menu; + if (!contextMenu.isOpen) return null; + const text = contextMenu.data?.text ?? ""; + return ( + + { closeContextMenu(); onClipboard("cut"); }} /> + { closeContextMenu(); onClipboard("copy"); }} /> + + + + ); +} diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index 237691790..256a54a9d 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -142,6 +142,38 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t ); } +interface RadioItemProps { + checked: boolean; + onSelect: () => void; + testId?: string; + children: React.ReactNode; +} + +/** One choice of a group (message priority); stays open like the check items. */ +export function ComposerToolbarMenuRadioItem({ checked, onSelect, testId, children }: RadioItemProps) { + return ( + + ); +} + interface PluginItemsProps { /** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */ surface: string; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 1d3f5f4ff..97fd4f83d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -33,8 +33,11 @@ import { ReplyAll, Reply, Forward, + BookUser, } from "@/components/icons"; import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu"; +import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu"; +import type { MessagePriority } from "@/lib/message-priority"; import { registerComposer } from "@/lib/plugin-sandbox/composer-registry"; import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, replaceOwnContent, replaceSelection as replaceEditorSelection, replaceTextareaRange, textToEditorHtml, type OwnTextOptions } from "@/lib/plugin-sandbox/composer-edit"; import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions"; @@ -57,6 +60,7 @@ import { plainComposeBodyToHtml, replaceEmbeddedSignature, replacePlainTextSignature, + replacePlainTextSignatureAboveQuote, } from "@/components/email/compose-format"; import { emailHooks, contactHooks, isExternalAttachmentResult } from "@/lib/plugin-hooks"; import { onUploadProgress } from "@/lib/upload-progress"; @@ -67,12 +71,15 @@ import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@ import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars"; +import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { usePluginActions } from "@/components/plugins/plugin-actions"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePickerDialog } from "@/components/files/file-picker-dialog"; +import { DirectoryPickerDialog } from "@/components/contacts/directory-picker-dialog"; +import type { DirectoryEntry } from "@/stores/contact-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; @@ -88,7 +95,7 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; -import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; +import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading"; import { sendFailureMessage } from "@/lib/send-failure-message"; import { RecipientsRejectedError, RequestTimeoutError, ScheduleTooLateError, SendUnconfirmedError, SubmissionQuotaError, formatRejectedRecipients } from "@/lib/jmap/client"; @@ -103,6 +110,7 @@ import { formatRecipientList, expandRecipients, splitPastedRecipients, + invalidRecipientAddresses, defaultSuggestionIndex, waitForPendingUploads, extractUserAuthoredText, @@ -114,7 +122,8 @@ import { import type { ComposePrefill } from "@/lib/plugin-compose-gate"; import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks"; import { isValidEmail } from "@/lib/validation"; -import { buildMentionCandidates } from "@/lib/recipient-mentions"; +import { buildMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions"; +import { normalizeCid } from "@/lib/attachment-visibility"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import { isRecipientMentionActive } from "@/components/email/recipient-mention"; import type { Editor } from "@tiptap/react"; @@ -180,11 +189,10 @@ export interface ComposerDraftData { draftId: string | null; /** * JMAP account `draftId` lives in, when it is not the composing login's - * own: a send undone, or a scheduled send cancelled for editing, from a - * group or shared identity puts the message back in that account's Drafts. - * Saving, sending and discarding must remove it there - email ids repeat - * across accounts. The first save moves the draft to the own Drafts, where - * every draft is saved. + * own: a group identity's drafts are saved in the group's Drafts (#1090), + * and a send undone, or a scheduled send cancelled for editing, puts the + * message back in the account it was sent from. Saving, sending and + * discarding must remove it there - email ids repeat across accounts. */ draftAccountId?: string; /** @@ -218,6 +226,8 @@ export interface ComposerDraftData { */ inReplyTo?: string[]; references?: string[]; + /** Priority of a re-opened draft, read back from its headers (#970). */ + priority?: MessagePriority; } interface EmailComposerProps { @@ -254,6 +264,8 @@ interface EmailComposerProps { requestDsn?: boolean; /** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */ requireTls?: boolean; + /** Importance / X-Priority; absent for normal (#970). */ + priority?: MessagePriority; }) => void | false | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; @@ -408,6 +420,9 @@ const DRAFT_HOOK_TIMEOUT_MS = 5000; /** Longest text a plugin may insert into the message at once. */ const MAX_PLUGIN_INSERT_LENGTH = 100_000; +/** How long after the last caret move the selection goes to the plugin frames. */ +const PLUGIN_SELECTION_DELAY_MS = 150; +const EMPTY_SELECTION = { text: '', empty: true } as const; export function EmailComposer({ onSend, @@ -464,6 +479,7 @@ export function EmailComposer({ const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const emptySubjectWarningEnabled = useSettingsStore((state) => state.emptySubjectWarningEnabled); const recipientMentionsEnabled = useSettingsStore((state) => state.recipientMentionsEnabled); + const mentionAddsRecipientTo = useSettingsStore((state) => state.mentionAddsRecipientTo); const updateSetting = useSettingsStore((state) => state.updateSetting); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); @@ -547,17 +563,35 @@ export function EmailComposer({ const toRecipient = (r: { name?: string; email?: string }): Recipient => ({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" }); - const ownIdentityEmails = identities.map(i => i.email).filter((e): e is string => Boolean(e)); + // Identities can still be loading when the composer opens (cold start, PWA + // relaunch), so the account's own addresses stand in until they arrive (#803). + const composerAccount = activeAccountId ? useAccountStore.getState().getAccountById(activeAccountId) : undefined; + const replyAccount = sourceAccountId && sourceAccountId !== activeAccountId + ? useAccountStore.getState().getAccountById(sourceAccountId) + : undefined; + const ownIdentityEmails = ownReplyAddresses( + identities.map(i => i.email), + [composerAccount?.email, composerAccount?.username, replyAccount?.email, replyAccount?.username], + ); + // "Self-sent" means sent by the account holding the original. A message + // another connected account sent to this one was received here, and its + // reply must go back to that sender, not to this account's own address. + const sourceAccount = replyAccount ?? composerAccount; + const sourceAccountEmails = ownReplyAddresses( + sourceAccountIdentities.map(i => i.email), + [sourceAccount?.email, sourceAccount?.username], + ); + const ownIdentityEmailsKey = [...ownIdentityEmails, '|', ...sourceAccountEmails].map((e) => e.toLowerCase()).join(','); // Initialize with reply/forward data if provided const getInitialTo = (): Recipient[] => { if (mode !== 'reply' && mode !== 'replyAll') return []; - return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient); + return buildReplyRecipients(replyTo, mode, ownIdentityEmails, sourceAccountEmails).to.map(toRecipient); }; const getInitialCc = (): Recipient[] => { if (mode !== 'replyAll') return []; - return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient); + return buildReplyRecipients(replyTo, mode, ownIdentityEmails, sourceAccountEmails).cc.map(toRecipient); }; const getInitialSubject = () => { @@ -570,6 +604,29 @@ export function EmailComposer({ return ""; }; + // The first line of the quote header getInitialBody writes under the reply + // area ("On ... wrote:", the forwarded-message line, or a plugin's header). + // A signature that arrives after the body was built - From resolves to a + // matching identity, or the identities load late - goes above it. + const getQuoteHeaderLine = (): string => { + if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return ''; + if (!replyTo?.body && !replyTo?.htmlBody) return ''; + const pluginHeader = replyTo.quoteHeaderText + ?? (replyTo.quoteHeaderHtml !== undefined ? htmlToPlainText(replyTo.quoteHeaderHtml) : undefined); + if (pluginHeader !== undefined) { + return pluginHeader.split('\n').map((line) => line.trim()).find(Boolean) ?? ''; + } + if (mode === 'forward') return tQuote('forwarded_separator'); + const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; + const from = replyTo.from?.[0]; + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || tCommon('unknown'))) + : tCommon('unknown'); + return tQuote('reply_line', { date, from: fromStrFull }); + }; + const getInitialBody = () => { if (plainTextMode) { // Plain text mode: produce plain text body with no HTML @@ -753,6 +810,34 @@ export function EmailComposer({ const [showCc, setShowCc] = useState(() => initialData?.showCc ?? (prefill?.cc ? prefill.cc.length > 0 : getInitialCc().length > 0)); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); + // The reply recipients worked out when the composer opened. When the + // identities arrive later (#803) they're worked out again, but only while + // To and Cc still hold exactly what was filled in, so nothing the user + // changed is overwritten. Drafts and prefilled composes are left alone. + const autoRecipientsRef = useRef<{ to: Recipient[]; cc: Recipient[] } | null>( + !initialData && !prefill && (mode === 'reply' || mode === 'replyAll') + ? { to, cc } + : null, + ); + useEffect(() => { + const auto = autoRecipientsRef.current; + if (!auto) return; + const same = (a: Recipient[], b: Recipient[]) => + a.length === b.length && a.every((r, i) => r.email === b[i].email && r.name === b[i].name); + if (!same(to, auto.to) || !same(cc, auto.cc)) { + autoRecipientsRef.current = null; + return; + } + const nextTo = getInitialTo(); + const nextCc = getInitialCc(); + if (same(nextTo, auto.to) && same(nextCc, auto.cc)) return; + autoRecipientsRef.current = { to: nextTo, cc: nextCc }; + setTo(nextTo); + setCc(nextCc); + if (nextCc.length > 0) setShowCc(true); + // Only a change in the user's own addresses should redo the recipients. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ownIdentityEmailsKey]); // Committed recipients plus any not-yet-committed text the user has typed. // Send/validation/draft paths treat a typed-but-uncommitted address as a // real recipient, matching the previous string-based behavior. @@ -765,6 +850,10 @@ export function EmailComposer({ // extension (RFC 8621 §1.3 submissionExtensions). const [requestDsn, setRequestDsn] = useState(false); const [requireTls, setRequireTls] = useState(false); + // Message priority (#970): normal writes no header at all. + const [priority, setPriority] = useState(initialData?.priority ?? 'normal'); + const priorityRef = useRef(priority); + priorityRef.current = priority; const [draftId, setDraftId] = useState(initialData?.draftId ?? null); // Mirror of draftId for synchronous reads inside chained saves; React's // setDraftId is async, so a queued saveDraft would otherwise see the old @@ -819,9 +908,10 @@ export function EmailComposer({ const embeddedCids = collectInlineImageCids(body); return replyTo.attachments // Skip inline cid-referenced images - they're embedded in the forwarded HTML body - // (matches the viewer's hideInlineImageAttachments logic). + // (matches the viewer's hideInlineImageAttachments logic). att.cid may still + // carry angle brackets while embeddedCids (parsed from the body) never does. .filter(att => !(att.cid && ( - embeddedCids.has(att.cid) || + embeddedCids.has(normalizeCid(att.cid)) || (att.disposition === 'inline' && (att.type || '').startsWith('image/')) ))) .map(att => ({ @@ -855,6 +945,7 @@ export function EmailComposer({ // The Files account being browsed for attachments, fixed when the picker // opens so the picked blobs are tagged with the login that owns them. const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null); + const [showDirectoryPicker, setShowDirectoryPicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); // The formatting toolbar is folded away on a phone until asked for. @@ -1075,11 +1166,20 @@ export function EmailComposer({ if (plainTextMode) { // A new plain-text message carries the signature at the end of the - // body; in a reply the quote follows it. - if (!identityChanged || mode !== 'compose') return; - setBody((prev) => replacePlainTextSignature(prev, previousIdentity, signatureIdentity, { - separator: signatureSeparatorEnabled, - })); + // body, a reply or forward above its quote header. + if (!identityChanged) return; + const options = { separator: signatureSeparatorEnabled }; + if (mode === 'compose') { + setBody((prev) => replacePlainTextSignature(prev, previousIdentity, signatureIdentity, options)); + return; + } + // The signature identity of a reply is often only known after the body + // was built: the reply's From resolves to the alias it was sent to, or + // the identities load late. Swap it above the quote header like an + // HTML body does; leaving it out lost it, since an above-quote reply + // is never signed at send time (#842). + const quoteHeader = getQuoteHeaderLine(); + setBody((prev) => replacePlainTextSignatureAboveQuote(prev, quoteHeader, previousIdentity, signatureIdentity, options)); return; } if (!editor) return; @@ -1095,7 +1195,7 @@ export function EmailComposer({ const nextHtml = replaceEmbeddedSignature(currentHtml, buildEmbeddedSignatureHtml(signatureIdentity, { embed: true, separator: signatureSeparatorEnabled, - })); + }), { quoteHeader: getQuoteHeaderLine() }); if (nextHtml !== currentHtml) { editor.commands.setContent(nextHtml, { emitUpdate: true }); } @@ -1164,10 +1264,10 @@ export function EmailComposer({ // identity that sent it. Resolving from the recipients here would pick the // *other* party's address - and on a catch-all domain it would even set a // From override to their address. An identity on the message's own account - // wins when two accounts share the address. - if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) { - const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0]) - ?? findDraftIdentityId(identities, replyTo?.from?.[0]); + // wins when two accounts share the address. Only the message's own account + // counts: mail another connected account sent here is a received message. + if (isSelfSent({ from: replyTo?.from }, sourceAccountIdentities.map(i => i.email).filter(Boolean))) { + const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0]); if (senderIdentityId) { setSelectedIdentityId(senderIdentityId); return; @@ -1264,15 +1364,20 @@ export function EmailComposer({ // lenient (email-viewer.tsx: `att.cid && att.blobId`), so reading and // replying now agree. The real type is sniffed from the bytes below. const embeddedCids = collectInlineImageCids(body); - const inlineAtts = replyTo.attachments.filter((att) => - att.cid && att.blobId && embeddedCids.has(att.cid) - ); + // Attachment cids may still carry the RFC angle brackets (e.g. + // ``) while `src="cid:logo123@sender"` in the body never + // does - normalize here the same way email-viewer.tsx and + // thread-conversation-view.tsx do, so the comparisons below actually + // match (#912: inline images silently missing from every reply/forward). + const inlineAtts = replyTo.attachments + .filter((att) => att.cid && att.blobId) + .map((att) => ({ ...att, cid: normalizeCid(att.cid!) })) + .filter((att) => embeddedCids.has(att.cid)); if (inlineAtts.length === 0) return; // Seed the ref synchronously so a fast Send still attaches the right blobs // even if the FileReader work below hasn't resolved yet. for (const att of inlineAtts) { - if (!att.cid) continue; if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue; inlineImagesRef.current.push({ cid: att.cid, @@ -1289,7 +1394,6 @@ export function EmailComposer({ (async () => { const updates = new Map(); for (const att of inlineAtts) { - if (!att.cid) continue; try { const buffer = await originClient.fetchBlobArrayBuffer( att.blobId, @@ -1410,6 +1514,27 @@ export function EmailComposer({ // recipient in the text would disclose them to everyone else. const mentionCandidates = useMemo(() => buildMentionCandidates(to, cc), [to, cc]); + // Contacts an "@" offers after the recipients; picking one adds them to + // To or Cc. Anyone already on the message is left out, Bcc and the + // identity's own Bcc included, so an "@" never names a blind copy. + const onMessage = useMemo(() => new Set( + expandRecipients([...to, ...cc, ...withInput(bcc, bccInput), ...(currentIdentity?.bcc ?? [])]) + .map((r) => r.email.trim().toLowerCase()), + ), [to, cc, bcc, bccInput, currentIdentity]); + const findMentionContacts = useCallback((query: string) => + getAutocomplete(query).filter((s) => !s.group && s.email && !onMessage.has(s.email.trim().toLowerCase())), + [getAutocomplete, onMessage]); + const addMentionedRecipient = useCallback((candidate: MentionCandidate) => { + const field = candidate.addsTo === 'cc' ? 'cc' : 'to'; + const setter = field === 'cc' ? setCc : setTo; + setter((prev) => { + const key = candidate.email.toLowerCase(); + if (expandRecipients(prev).some((r) => r.email.trim().toLowerCase() === key)) return prev; + return [...prev, { name: candidate.name, email: candidate.email }]; + }); + if (field === 'cc') setShowCc(true); + }, [setTo, setCc, setShowCc]); + // Uploaded/hydrated attachments in ComposerDraftData shape, so a state // snapshot (pro tab move, unmount save) carries them across a remount // instead of silently dropping them (#849). In-flight/failed uploads have @@ -1451,6 +1576,17 @@ export function EmailComposer({ // `!==`, not `>`: a re-opened draft starts with hydrated attachments, and // removing one must count as dirty or the removal never reaches the server. body !== initialValuesRef.current.body || attachments.length !== initialValuesRef.current.attachmentCount; + // A priority change alone is an edit worth saving (#970). + const initialPriorityRef = useRef(priority); + if (priority !== initialPriorityRef.current) isDirtyRef.current = true; + const firstPriorityRunRef = useRef(true); + useEffect(() => { + if (firstPriorityRunRef.current) { + firstPriorityRunRef.current = false; + return; + } + changeVersionRef.current += 1; + }, [priority]); // Ref to latest saveDraft for use in event handlers with stale closures const saveDraftRef = useRef<() => Promise>(() => Promise.resolve(null)); @@ -1494,8 +1630,10 @@ export function EmailComposer({ const s = stateRef.current; onSaveState({ ...s, + draftAccountId: draftAccountIdRef.current, mode, replyTo, + priority: priorityRef.current, }); } }; @@ -1699,6 +1837,26 @@ export function EmailComposer({ } }, [autoQuery, composerClient, isSearchingServer, searchRecipients]); + // Recipients picked in the directory dialog; addresses already in that + // field are skipped. + const addDirectoryRecipients = useCallback((field: 'to' | 'cc' | 'bcc', entries: DirectoryEntry[]) => { + const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc; + setter(prev => { + const seen = new Set(prev.filter(r => !r.group).map(r => r.email.toLowerCase())); + const next = [...prev]; + for (const e of entries) { + const key = e.email.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + next.push({ name: e.name !== e.email ? e.name : undefined, email: e.email }); + } + return next; + }); + if (field === 'to' && validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false })); + if (field === 'cc') setShowCc(true); + if (field === 'bcc') setShowBcc(true); + }, [validationErrors.to]); + const insertAutocomplete = (suggestion: SuggestionItem, field: 'to' | 'cc' | 'bcc') => { if (autocompleteTimeoutRef.current) clearTimeout(autocompleteTimeoutRef.current); setAutoQuery(''); @@ -2177,10 +2335,15 @@ export function EmailComposer({ const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); // The plugin side panels fold when the composer gets too narrow for them. const [composerRootEl, setComposerRootEl] = useState(null); - const pluginSidebars = useComposerSidebars(composerRootEl); + // The id plugins know this composer by (registered below, with the handle). + const [pluginComposerId, setPluginComposerId] = useState(null); + const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source'); const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0; const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0; + const hasLeftSidebarOffers = usePluginSlotOffers('composer-sidebar').length > 0; + const hasRightSidebarOffers = usePluginSlotOffers('composer-sidebar-right').length > 0; + const hasSidebarOffers = hasLeftSidebarOffers || hasRightSidebarOffers; // Plugin entries make the Send and Attach buttons menus even where the app // itself has nothing else to put there. const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0; @@ -2335,7 +2498,7 @@ export function EmailComposer({ // draft version (below), and hashing them would mark each save dirty // again - an endless save loop. Name+type+size identifies an attachment // for change detection. - const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: draftHtmlBody ?? draftTextBody, attachments: uploadedAttachments.map(({ name, type, size, cid }) => ({ name, type, size, cid })), identityId: selectedIdentityId, subAddressTag }); + const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body: draftHtmlBody ?? draftTextBody, attachments: uploadedAttachments.map(({ name, type, size, cid }) => ({ name, type, size, cid })), identityId: selectedIdentityId, subAddressTag, priority }); // Only save if data has changed if (currentData === lastSavedDataRef.current) { @@ -2401,7 +2564,12 @@ export function EmailComposer({ // Use the JMAP client and raw identity id for the *owning* account // - falls back to active client for single-account / same-account - // identities. See `composerClient` derivation above. + // identities. See `composerClient` derivation above. Within that login + // the draft goes to the account owning the From identity (a group's own + // Drafts), and the replaced version is destroyed where it lives (#1090). + const draftAccountId = composerClient.resolveSendAccountId + ? await composerClient.resolveSendAccountId(savedDraft.fromEmail) + : undefined; const savedDraftId = await composerClient.createDraft( savedDraft.to, savedDraft.subject, @@ -2414,9 +2582,9 @@ export function EmailComposer({ savedDraft.attachments, savedDraft.fromName, savedDraft.htmlBody, - replacedElsewhere ? undefined : previousDraftAccountId, replyThreadingHeaders?.inReplyTo, replyThreadingHeaders?.references, + { priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId }, ); if (replacedElsewhere) { await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId); @@ -2424,12 +2592,12 @@ export function EmailComposer({ // Update the ref synchronously so a queued save sees the new id and // doesn't try to destroy the just-replaced draft. The new version is in - // the saving login's own account, wherever the previous one was. + // the account owning the From identity, through the saving login. draftIdRef.current = savedDraftId; - draftAccountIdRef.current = undefined; + draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; setDraftId(savedDraftId); - setDraftAccountId(undefined); + setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); lastSavedDataRef.current = currentData; savedVersionRef.current = versionAtStart; @@ -2444,7 +2612,7 @@ export function EmailComposer({ const savedBlobIds = new Set(uploadedAttachments.map(att => att.blobId)); if (savedBlobIds.size) { try { - const freshDraft = await composerClient.getEmail(savedDraftId); + const freshDraft = await composerClient.getEmail(savedDraftId, draftAccountId); const freshParts = (freshDraft?.attachments ?? []).filter(p => !!p.blobId); if (freshParts.length) { const remap = (list: ComposerAttachment[]): ComposerAttachment[] => { @@ -2537,7 +2705,7 @@ export function EmailComposer({ // composer-above-body / composer-below-body get the draft when they appear // and then 2 seconds after the last change, like onDraftChange: a new prop // object on every keystroke would post the whole body into their iframes. - const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers; + const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers || hasSidebarOffers; const [pluginDraft, setPluginDraft] = useState | null>(null); const pluginDraftShownRef = useRef(false); useEffect(() => { @@ -2603,7 +2771,6 @@ export function EmailComposer({ }; const pluginAttachRef = useRef(handlePluginAttachmentAdd); pluginAttachRef.current = handlePluginAttachmentAdd; - const [pluginComposerId, setPluginComposerId] = useState(null); useEffect(() => { const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current); const { id, unregister } = registerComposer({ @@ -2655,10 +2822,108 @@ export function EmailComposer({ return unregister; }, []); + // The selected text of the message, for the plugin frames around it and + // the panels beside it (an assistant works on what the user highlights). + // Read from the editor's own selection state, which stays put while the + // user clicks into a panel, and sent only when it changed, a moment after + // the last change: a prop object per caret move would flood the frames. + const wantsPluginSelection = wantsPluginDraft; + const [pluginSelection, setPluginSelection] = useState<{ text: string; empty: boolean }>(EMPTY_SELECTION); + const [pluginEditor, setPluginEditor] = useState(null); + // One callback for the composer's life: the editor reports itself ready + // whenever this changes, and every report now sets state, so a new arrow + // per render would report, render and report again without end. + const handleEditorReady = useCallback((ed: Editor) => { + editorRef.current = ed; + setPluginEditor(ed); + if (!replyFocusedRef.current && (mode === 'reply' || mode === 'replyAll')) { + replyFocusedRef.current = true; + ed.commands.focus('start'); + } + }, [mode]); + useEffect(() => { + if (!wantsPluginSelection) return; + let timer: ReturnType | null = null; + const publish = () => { + timer = null; + let next: { text: string; empty: boolean } = EMPTY_SELECTION; + if (plainTextModeRef.current) { + const textarea = bodyRef.current; + if (textarea) { + const text = textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0); + next = text ? { text, empty: false } : EMPTY_SELECTION; + } + } else if (pluginEditor) { + const { text, empty } = readEditorSelection(pluginEditor); + next = empty ? EMPTY_SELECTION : { text, empty: false }; + } + setPluginSelection((prev) => (prev.empty === next.empty && prev.text === next.text ? prev : next)); + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(publish, PLUGIN_SELECTION_DELAY_MS); + }; + // The textarea has no selection event of its own that covers the + // keyboard; the document's selectionchange fires for it. + const onDocumentSelection = () => { + if (plainTextModeRef.current && document.activeElement === bodyRef.current) schedule(); + }; + document.addEventListener('selectionchange', onDocumentSelection); + pluginEditor?.on('selectionUpdate', schedule); + publish(); + return () => { + if (timer) clearTimeout(timer); + document.removeEventListener('selectionchange', onDocumentSelection); + pluginEditor?.off('selectionUpdate', schedule); + }; + }, [wantsPluginSelection, pluginEditor, plainTextMode]); + const pluginDraftProps = useMemo( - () => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), - [pluginDraft, pluginInsertText, pluginSelectedText, pluginComposerId], + () => ({ draft: pluginDraft, selection: pluginSelection, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), + [pluginDraft, pluginSelection, pluginInsertText, pluginSelectedText, pluginComposerId], ); + const pluginSidebarLeftProps = useMemo( + () => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId, focusRequest: pluginSidebars.focusRequests.left }), + [pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.left], + ); + const pluginSidebarRightProps = useMemo( + () => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId, focusRequest: pluginSidebars.focusRequests.right }), + [pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.right], + ); + + // The right-click menu on selected text, when a plugin put entries in it. + const selectionMenu = useComposerSelectionMenu(); + const readSelectedText = useCallback((): string => { + if (plainTextModeRef.current) { + const textarea = bodyRef.current; + return textarea ? textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0) : ''; + } + const editor = editorRef.current; + return editor ? readEditorSelection(editor).text : ''; + }, []); + const onBodyContextMenu = useCallback((e: React.MouseEvent) => { + selectionMenu.onContextMenu(e, readSelectedText); + }, [selectionMenu, readSelectedText]); + // Cut and Copy from that menu: the browser's own command, run with the + // selection active again, keeps formatting on the clipboard; when the + // command is not available the plain text goes there. + const clipboardFromMenu = useCallback((command: 'cut' | 'copy') => { + const text = readSelectedText(); + const plain = plainTextModeRef.current; + const textarea = bodyRef.current; + const editor = editorRef.current; + if (plain) textarea?.focus(); else editor?.commands.focus(); + let done = false; + try { done = typeof document.execCommand === 'function' && document.execCommand(command); } catch { done = false; } + if (done) return; + void navigator.clipboard?.writeText(text).catch(() => {}); + if (command !== 'cut') return; + if (plain) { + if (textarea) replaceTextareaRange(textarea, textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0, ''); + } else { + editor?.commands.deleteSelection(); + } + }, [readSelectedText]); const pluginToolbarProps = useMemo( () => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }), [pluginInsertText, pluginSelectedText, pluginComposerId], @@ -2703,7 +2968,7 @@ export function EmailComposer({ } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up - }, [toStr, ccStr, bccStr, subject, body, attachments]); + }, [toStr, ccStr, bccStr, subject, body, attachments, priority]); useEffect(() => { return () => { @@ -2892,11 +3157,11 @@ export function EmailComposer({ // Catch addresses the server would reject before asking it. Its answer // ("No recipients found in email") does not say which one is wrong. - const invalidRecipients = [ + const invalidRecipients = invalidRecipientAddresses([ ...withInput(to, toInput), ...withInput(cc, ccInput), ...withInput(bcc, bccInput), - ].filter(r => !r.group && !isValidEmail(r.email)).map(r => r.email); + ]); if (invalidRecipients.length > 0) { toast.error(t('validation.invalid_recipients', { addresses: invalidRecipients.join(', ') })); return; @@ -3185,6 +3450,7 @@ export function EmailComposer({ requestReadReceipt, requestDsn: requestDsn || undefined, requireTls: requireTls || undefined, + priority: priority !== 'normal' ? priority : undefined, delayedUntil: effectiveDelayedUntil, }); // Not when nothing went: no host, or one that sent nothing. @@ -3459,7 +3725,7 @@ export function EmailComposer({ className={cn("flex h-full bg-background relative", className)} onKeyDownCapture={handleComposerKeyDown} > - pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} /> + pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarLeftProps} width={pluginSidebars.widths.left} onWidthChange={(w) => pluginSidebars.setSideWidth('left', w)} /> {/* Right-side composer sidebar slot is rendered after the main content div below. */}
+ {activeClient?.supportsPrincipals?.() && ( + + )} )} + {selectionCanLeaveFolder && ( + + )}