Compare commits

..
268 Commits
Author SHA1 Message Date
Linus Rath 1e7d2d880c i18n: add missing translation keys across 16 locales 2026-05-22 00:44:00 +02:00
Linus Rath 63efd724d2 fix: trust directory version on marketplace install/update 2026-05-22 00:20:00 +02:00
Linus Rath ba4781910d feat: marketplace update flow for installed plugins/themes 2026-05-22 00:11:10 +02:00
Linus Rath 08c85a42e1 chore: update version to 1.7.0 2026-05-21 23:47:31 +02:00
Linus Rath fc5f6f43d6 feat: expose PWA, app identity, and extension directory keys in JSON config #312 2026-05-21 23:35:58 +02:00
Linus Rath 9b22ef810e fix: allow adding contacts from mail recipient popover on mobile #306 2026-05-21 23:23:45 +02:00
Linus Rath dbc0eea148 feat: group composer From dropdown by account in Pro shell 2026-05-21 23:07:02 +02:00
Linus Rath e80412b6fd feat: show contacts from all logged-in accounts in Pro shell 2026-05-21 22:32:17 +02:00
Linus Rath 2c825af689 fix: show avatars in calendar/address book sharing menu 2026-05-21 18:58:46 +02:00
Linus Rath 4cfee4f672 feat: split owned vs shared calendars per account in sidebar in Pro shell 2026-05-21 18:54:09 +02:00
Linus Rath 7076f1ded8 feat: show calendars from all logged-in accounts in Pro shell 2026-05-21 18:48:55 +02:00
Linus Rath 9fbcdf7a5f fix: hide mail sidebar header in Pro shell 2026-05-21 18:19:30 +02:00
Linus Rath 7f35d792b2 fix: load globals.css and Geist font in plugin sandbox iframe 2026-05-21 18:15:00 +02:00
Linus Rath c9cda3e203 fix: parent navigation detaching account in Pro shell file browser 2026-05-21 18:06:59 +02:00
Linus Rath 3540bf42d9 fix: use Avatar component in Pro shell file account picker 2026-05-21 18:05:03 +02:00
Linus Rath e6782e61a8 fix: sync plugin slot iframe height with reported content height 2026-05-21 17:59:24 +02:00
Linus Rath 33e655bcce feat: narrow-pane sidebars and cross-account file picker in Pro shell 2026-05-21 17:54:03 +02:00
Linus Rath 15a67a14b3 fix: parent dir navigation jumping to root in file browser 2026-05-21 17:27:49 +02:00
Linus Rath b48eef2189 feat: hide files back button in Pro shell 2026-05-21 17:25:01 +02:00
Linus Rath 280f5bc675 feat: support cross-account email moves in Pro shell 2026-05-21 17:22:31 +02:00
Linus Rath 1756f5ac1c feat: enable search in unified mailbox in pro mode 2026-05-21 17:12:06 +02:00
Linus Rath c9435f7580 feat: always show unified mailbox in Pro shell 2026-05-21 17:01:34 +02:00
Linus Rath 426d344aa8 feat: multi-account mail sidebar and client routing for Pro shell 2026-05-21 16:52:03 +02:00
Linus Rath ed90e096b5 feat: add per-account mailbox cache for Pro shell data layer 2026-05-21 16:11:26 +02:00
Linus Rath eb6e5f589e fix: hide redundant account switcher in mail sidebar inside Pro shell 2026-05-21 15:54:33 +02:00
Linus Rath eca837962c fix: hide "Back to Mail" in settings when Pro mode is on 2026-05-21 15:49:09 +02:00
Linus Rath b75bbaa517 feat: auto-redirect to Pro shell when proInterface is on 2026-05-21 15:47:05 +02:00
Linus Rath 9763ffa2a3 fix: keep proInterface per-device instead of syncing it 2026-05-21 15:39:02 +02:00
Linus Rath d854b903e0 fix: anchor unmatched URLs into main so 404 renders 2026-05-20 23:59:10 +02:00
Linus Rath 5008857880 fix: respect server-resolved locale on first visit #309 2026-05-20 23:53:44 +02:00
Linus Rath 628966d3b5 fix: split app into (main)/(sandbox) route groups so plugin iframe hydrates properly 2026-05-20 23:41:49 +02:00
Linus Rath d45c8ef511 feat: list and reorder logged-in accounts in settings #282 2026-05-20 19:22:44 +02:00
Linus Rath ba90ec1f7a feat: warn when setup JMAP URL points at a local-only host 2026-05-20 19:11:46 +02:00
Linus Rath 5023d31202 fix: defer setup wizard HTTP detection to avoid hydration mismatch 2026-05-20 19:05:11 +02:00
Linus Rath 1c44f59ba1 feat: allow setup wizard over plain HTTP with dismissable warning gate 2026-05-20 19:01:46 +02:00
Linus Rath 433a63bf1a fix: normalize malformed contact photo data URIs #307 2026-05-20 18:42:12 +02:00
Linus Rath de847b9e9f i18n: add missing translation keys across 16 locales 2026-05-20 18:30:50 +02:00
Linus Rath d530d9614b fix: serialize draft autosave with send to stop replies stalling in Drafts #303 2026-05-19 23:33:11 +02:00
Linus Rath 9a92271f6f fix: prevent mobile dual-scroll and use full width for mail content 2026-05-19 14:38:28 +02:00
Linus Rath 97ddf935a8 fix: mobile handoff flow for OAuth authentication 2026-05-19 00:45:35 +02:00
Linus Rath 973ce1e5bd feat: add mobile handoff page and JMAP authentication verification 2026-05-19 00:05:40 +02:00
Linus Rath 7003020855 fix: prevent duplication of Bulwark rules with literal braces in values 2026-05-18 23:58:21 +02:00
Linus Rath 5cdc5997af feat: pro: pane-aware responsiveness, scoped sidebar overlay, stable pane keys 2026-05-18 20:42:21 +02:00
Linus Rath c3b4707f85 feat: pro: drop top/bottom split, keep side-by-side only 2026-05-18 19:51:43 +02:00
Linus Rath 2b3094a4ef feat: pro: unify split panes under a single tab bar 2026-05-18 19:39:57 +02:00
Linus Rath ecd0467ffa fix: stop pulling node:dns into client bundle via OAuth discovery 2026-05-18 19:31:38 +02:00
Linus Rath 43ac0725ce feat: pro: drag tabs to reorder, drag to edge to split 2026-05-18 19:30:54 +02:00
Linus Rath 98879802ae feat: add Pro interface 2026-05-18 19:17:39 +02:00
Linus Rath cf9292262d feat: pluggable reply/forward quote header #295 2026-05-18 17:53:23 +02:00
Linus Rath 3d2ed71f3a feat: support multiple flexible event reminders #170 2026-05-18 17:31:44 +02:00
Linus Rath dcd2f4b079 fix: wire orphaned admin policy gates and surface OAuth scope settings 2026-05-18 16:47:20 +02:00
Linus Rath b2d24670ff fix: scope iCal subscriptions per JMAP account and fix refresh/clear 2026-05-18 16:39:02 +02:00
Linus Rath fa261fecfd fix: omit empty cc/bcc from Email/set so server does not emit bare Cc: header #301 2026-05-18 16:36:45 +02:00
Linus Rath 40f36baf94 fix: iCal subscription refresh, rollback, and URL normalization 2026-05-18 16:31:16 +02:00
Linus Rath 400703154e fix: ignore plugin-supplied target in ui.openExternalUrl to block host-frame hijack 2026-05-18 16:18:00 +02:00
Linus Rath 3ceada7b8a fix: tighten HTML sanitization at plain-text email + signature + i18n render sites 2026-05-18 16:07:34 +02:00
Linus Rath eb0643d887 fix: pin parent origin in iframe-bridge to block cross-frame postMessage 2026-05-18 16:01:29 +02:00
Linus Rath 1fc670138b fix: update bundleHash to full SHA-256 for integrity verification and migrate legacy hashes 2026-05-18 15:57:52 +02:00
Linus Rath 313a1fcce9 fix: stop persisting S/MIME passphrases in sessionStorage 2026-05-18 15:32:04 +02:00
Linus Rath 7efd8d59bf fix: escape print-window fields and re-sanitize body to block XSS 2026-05-18 13:24:52 +02:00
Linus Rath c2eb2c081b fix: gate admin routes against cross-origin CSRF 2026-05-18 13:21:01 +02:00
Linus Rath b299a0b602 fix: validate plugin/theme id in marketplace install to block path traversal 2026-05-18 13:03:49 +02:00
Linus Rath f275fbe2e4 fix: bind stalwart auth context to credential, not cookie-claimed username 2026-05-18 13:00:40 +02:00
Linus Rath f134766fd1 fix: validate OAuth discovery endpoints against SSRF 2026-05-18 12:53:43 +02:00
Linus Rath 6ebf720688 fix: block script-bearing MIME types from inline attachment preview 2026-05-18 12:47:44 +02:00
Linus Rath b1eb2b3c9b fix: correct regex for valid API post path validation 2026-05-18 12:44:54 +02:00
Linus Rath 48aa607b56 feat: lock down plugin runtime in sandbox + signing + approval 2026-05-18 12:44:23 +02:00
Linus Rath 088810bd20 feat: harden plugin sandbox and migrate in-tree plugins 2026-05-18 12:17:54 +02:00
Linus Rath e16f572252 fix: use plugin slot offer snapshots for useSyncExternalStore 2026-05-18 11:00:05 +02:00
Linus Rath 9f312aa556 feat: sandbox plugins in null-origin iframes with postMessage RPC 2026-05-18 10:50:49 +02:00
Linus Rath c5ac68e137 fix: prevent plugin config leak to non-admin users 2026-05-18 10:24:29 +02:00
Linus Rath ed6b5d5f33 fix: clear identity signature fields when emptied 2026-05-18 00:53:54 +02:00
Linus Rath 7a72903632 feat: show size cap on identity signature fields 2026-05-18 00:34:01 +02:00
Linus Rath 92127e2f00 fix: allow table-based layouts in HTML signature sanitizer 2026-05-18 00:24:08 +02:00
Linus Rath be5ff96e4d fix: toggle recipient popover when clicking name again 2026-05-17 23:45:57 +02:00
Linus Rath 1f47b7a6a9 fix: remove white halo around photo avatars 2026-05-17 23:44:13 +02:00
Linus RathandGitHub 551984ac44 Bump version from 1.6.6 to 1.6.7 2026-05-17 19:22:07 +02:00
Linus Rath 8c5aec9ca4 chore: update version to 1.6.7 2026-05-17 18:17:13 +02:00
Linus Rath 375220298d i18n: add missing translation keys across 16 locales 2026-05-17 18:12:17 +02:00
Linus Rath 452976ed95 fix: apply dark background to email content wrapper in dark mode 2026-05-17 17:40:35 +02:00
Linus Rath 243a2adfbf fix: improve dark mode background colors in email viewer 2026-05-17 17:39:49 +02:00
Linus Rath 1ba4a13353 fix: show "no body content" instead of infinite skeleton for bodyless emails 2026-05-17 17:33:06 +02:00
Linus Rath 5de12dfb79 perf: speed up calendar invitation banner load
Parallelize ICS parse with raw blob fetch, render the banner as soon
as parsing returns instead of awaiting the existing-event lookup, and
filter that lookup by UID server-side instead of fetching every event
on the calendar.
2026-05-17 17:28:10 +02:00
Linus Rath 689d646c57 fix: show contact popup when clicking sender name in email header 2026-05-17 17:19:43 +02:00
Linus Rath 49cd7f8130 feat: show details toggle and panel on mobile sender info 2026-05-17 17:15:19 +02:00
Linus Rath 4545e212f4 fix: align quick reply with mobile bottom toolbar 2026-05-17 17:06:30 +02:00
Linus Rath b1f4f6eae0 fix: pin quick reply to bottom for short emails 2026-05-17 16:56:54 +02:00
Linus Rath 9a431a873b fix: close attachment preview when clicking outside content 2026-05-17 16:49:13 +02:00
Linus Rath bb7e1c4538 fix: per-account push subscriptions so multi-account notifications work #298 2026-05-16 22:50:01 +02:00
Linus Rath 356abcfc2d fix: redact sensitive config secrets from admin API response 2026-05-16 22:48:06 +02:00
Linus Rath 3099b4801e fix: sandbox thread email HTML in srcDoc iframe with CSP meta 2026-05-16 20:49:59 +02:00
Linus Rath fc641e94ac fix: carry configSchema + settingsSchema through marketplace install 2026-05-16 19:51:59 +02:00
Linus Rath 0e758409ee fix: prevent long addresses from overflowing email details columns #297 2026-05-16 19:47:03 +02:00
Linus Rath 8c93941d8d feat: render app-top-banner slot on every authenticated page 2026-05-16 19:39:45 +02:00
Linus Rath 4221c9a50f fix: strip Stalwart master-user '%' suffix from displayed account 2026-05-16 19:07:10 +02:00
Linus Rath 3a559479bd fix: make impersonation cookies session-only 2026-05-16 18:59:46 +02:00
Linus Rath 482493a10d fix: register app-top-banner in plugin-store SLOT_NAMES 2026-05-16 18:53:06 +02:00
Linus Rath 0e1036eb49 fix: adopt orphan session cookie on first SPA load 2026-05-16 18:45:30 +02:00
Linus Rath 349406723c fix: use relative Location header in redirect 2026-05-16 18:33:44 +02:00
Linus Rath 997bedc91b feat: allow admin password overwrite during setup recovery 2026-05-16 18:21:48 +02:00
Linus Rath 307e6d5d34 fix: warn + block install when app version is below plugin's minAppVersion 2026-05-16 18:11:52 +02:00
Linus Rath ca1108f455 feat: master-user impersonation route + app-top-banner plugin slot 2026-05-16 17:59:07 +02:00
Linus Rath 0ff88f36ed Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-16 16:35:43 +02:00
Linus Rath 285b4e349c fix: add outputFileTracingExcludes to optimize Turbopack memory tracing 2026-05-16 16:35:06 +02:00
Linus Rath 2b4ebb1fbb feat: add HTTPS requirement warning in setup wizard 2026-05-16 16:31:12 +02:00
Timo StreuleandLinus Rath a829c2818f fix: pad safe-area-inset-top 2026-05-16 00:55:24 +02:00
Timo StreuleandLinus Rath c54cf73c3a fix: respect safe-area insets on mobile bottom bars 2026-05-15 23:50:29 +02:00
Timo StreuleandLinus Rath c45ef86924 fix: add viewport export with 'initialScale: 1' 2026-05-15 23:01:39 +02:00
Linus Rath f39366b470 fix: read OAUTH_SCOPES at runtime instead of build time 2026-05-15 20:37:00 +02:00
Linus Rath b725000f4d feat: implement vCard 4.0 parsing and generation support 2026-05-15 20:10:18 +02:00
Linus Rath 105194a8b9 chore: update version to 1.6.6 2026-05-15 15:20:07 +02:00
Linus Rath 8dbb538c98 feat: sync onboarding status across devices #285 2026-05-15 15:09:42 +02:00
Linus Rath e435356c53 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-15 14:49:58 +02:00
Linus Rath 6f9982540c feat: add icons for shared, important, memos, scheduled, snoozed folders #288 2026-05-15 14:48:39 +02:00
Timo StreuleandLinus Rath d0d6632b24 chore: drop redundant '-- ' prefix from dev identity signatures
The signature separator is already controlled by the
signatureSeparatorEnabled setting (lib/email-composer), which prepends
'-- ' at compose time when enabled. Baking it into the fixture
double-prefixed it.
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 4b7009dfc2 feat: raise HTML signature length cap to 50000 chars
5000 chars is too tight for signatures containing base64-embedded images (even a small PNG can run a few thousand chars).
2026-05-15 14:46:43 +02:00
Timo StreuleandLinus Rath 55a408e810 feat: allow img in HTML identity signatures
- Restricts src to https: URLs or base64-embedded raster data: URIs (png/jpeg/gif/webp).
- SVG is excluded for safety reasons.
- Images with a disallowed src are removed entirely so they don't render as broken-image icons.
2026-05-15 14:46:43 +02:00
Linus Rath d5dddba6df fix: hide Files settings/nav when filesEnabled policy is off #291 2026-05-15 14:41:57 +02:00
Linus Rath d1a0667c79 i18n: clean up Danish locale wiring and sort language lists 286 2026-05-15 14:31:24 +02:00
Jesper OrdrupandLinus Rath e700e4fd04 match any translation 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath cf993c1036 adjust flag 2026-05-15 14:26:33 +02:00
Jesper OrdrupandLinus Rath 5fdf226ebe feat(i18n): add danish localization 2026-05-15 14:26:33 +02:00
Linus Rath fae15f073e fix: honor cookieSameSite admin config override #284 2026-05-14 21:49:37 +02:00
Linus Rath c646c87030 fix: standardize punctuation in tooltips and comments across multiple locales and code files 2026-05-14 21:44:24 +02:00
Linus Rath b4a76bc4d1 chore: expand demo fixtures with more emails, contacts, and portrait photos 2026-05-14 15:19:24 +02:00
Linus Rath dfe886636b fix: broaden body font for non Latin script rendering #265 2026-05-13 14:43:07 +02:00
Linus Rath f499e87d2a chore: update version to 1.6.5 2026-05-13 14:38:00 +02:00
Linus Rath 32fe871b70 fix: support HTTP basic auth in iCal subscription URLs #275 2026-05-13 14:27:54 +02:00
Linus Rath aab19379e2 feat: route account avatars through shared Avatar component #278 2026-05-13 00:50:46 +02:00
Linus Rath b46a1a69e8 chore: unblock pre-commit lint hook 2026-05-13 00:34:35 +02:00
Linus Rath ea424cad7e fix: honor admin-uploaded favicon in root metadata #274 2026-05-13 00:33:23 +02:00
Lucas GaitzschandLinus Rath 3f444a8912 Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links
* Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab
* Added webcal: protocol handling for calendar links
* Added account selection for webcal: links when multiple calendar-capable accounts are connected
* Added an import-or-subscribe choice for detected webcal calendars
* Added protocol handler settings for registering mail and calendar handlers and choosing the open mode
* Added service worker/session coordination for passing protocol requests between browser/PWA contexts
* Added tests and translations for the new protocol handler flows
2026-05-12 20:49:05 +02:00
Linus Rath 8b0e2052cf fix: honor NEXT_PUBLIC_BASE_PATH in admin sidebar nav links #271 2026-05-12 16:10:29 +02:00
Linus Rath c99934a92c fix: update version to 1.6.4 2026-05-12 16:06:14 +02:00
Linus Rath ce2731cd9d fix: update types for cursor and toRemove 2026-05-12 16:04:46 +02:00
Linus Rath f9f8af2f11 fix: preserve signature styling and reactivity in above-quote mode #272 2026-05-12 16:03:10 +02:00
Linus Rath d8e2a10806 docs: update CONTRIBUTING.md 2026-05-11 20:41:04 +02:00
Linus Rath 869ee07ebc chore: bump next to 16.2.6 for security advisories 2026-05-11 20:11:05 +02:00
Linus Rath 2ad2bb1e09 chore: update version to 1.6.4 2026-05-11 20:00:55 +02:00
Linus Rath 23bc31c661 i18n: add missing translation keys across 15 locales 2026-05-11 19:30:52 +02:00
Linus Rath a2f76037a1 feat: update README and FEATURES.md 2026-05-11 19:24:32 +02:00
Linus Rath 9571f2e185 fix: skip upstream JMAP reverify for trusted URLs #237 2026-05-11 19:22:37 +02:00
Linus Rath 887b9c728c feat: drag attachments out to local file system #267 2026-05-11 17:34:24 +02:00
Linus Rath 8c21f462c2 feat: add signature position to email behavior settings search 2026-05-11 17:07:24 +02:00
Linus Rath 5f3d2d3e4a feat: signature above quoted text option #266 2026-05-11 17:05:59 +02:00
Linus Rath 4bce80b8ba feat: show avatar in Focused list for compact density and above 2026-05-11 16:23:15 +02:00
Linus Rath 1d09f5a623 feat: align Focused list preview with other layout previews 2026-05-11 15:41:25 +02:00
Linus Rath 2c513129f2 feat: add Reading Pane at Bottom mail layout #262 2026-05-11 15:35:43 +02:00
Linus Rath b3dc2e32b8 feat: implement prefetching of initial email data 2026-05-11 15:17:30 +02:00
Augustin MarcinandLinus Rath b0640c9ecc feat(compose): From override + catch-all auto-reply (fixes #246)
Adds an Override toggle in the composer's From row. When enabled, name
and address become free-text inputs. Mail is still submitted through the
selected identity, but the outgoing message's From: header — and the
SMTP envelope MAIL FROM when different — is set from the override.

The existing "Auto-select Reply Address" setting is extended: if the
incoming message was addressed to an alias on a domain that matches one
of your identities but isn't itself an identity (classic domain catch-
all), it now auto-enables Override and pre-fills the alias. Quick reply
honors the same resolution. The setting is relabeled to reflect the
broader behavior.

JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the
EmailSubmission includes an explicit envelope with that mailFrom and the
to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3).

S/MIME: override is incompatible with sign/encrypt and is refused with a
clear error — signing a different visible From from the identity's
certificate Subject would produce messages clients reject.

Tests: resolveReplyFrom covers exact match, sub-address stripping,
catch-all detection, identity preference, and foreign-domain null.
2026-05-11 12:13:47 +02:00
Linus Rath 2d7e24b513 perf: parallelize login round-trips and drop redundant JMAP re-verify 2026-05-11 11:04:13 +02:00
Linus Rath 5b30bacf10 feat: redesign review step with grouped summary and advanced toggle 2026-05-10 01:09:00 +02:00
Linus Rath fe937403f3 style: consistent notice cards for server probe results 2026-05-09 21:43:04 +02:00
Linus Rath 876ea370e4 feat: allow file uploads on the wizard branding step 2026-05-09 21:40:39 +02:00
Linus Rath 1dcdeeae86 style: consistent notice cards for server probe results 2026-05-09 18:12:43 +02:00
Linus Rath 01302a775c feat: require explicit confirmation when JMAP probe finds no session 2026-05-09 17:52:44 +02:00
Linus Rath 76d78ae756 fix: drop redundant first-login banner about removing ADMIN_PASSWORD #222 2026-05-09 17:40:53 +02:00
Linus Rath 51745ea03d feat: web setup wizard + admin config/state dir split (#226) 2026-05-09 17:37:41 +02:00
ChanceandLinus Rath c44a9ce6e0 fix: fall back to primary identity signature on reply
When auto-select picks an alias identity matching the original recipient,
the alias often has no signature configured. The composer was using the
alias's empty signature for both the visual preview and the appended
signature on send, so neither showed up. New mail worked because no
auto-select runs.

Add a signatureIdentity that falls back to the primary when the current
identity has no signature. From address, identity ID, S/MIME, and draft
saves still use currentIdentity so mail goes out from the right address.
2026-05-09 14:21:05 +02:00
Linus Rath 7fa65796f0 fix: show account identity in switcher header instead of sending alias 2026-05-09 13:21:13 +02:00
Linus Rath d09df7e8a3 fix: remove benchmark directory from .gitignore 2026-05-09 13:13:08 +02:00
Linus Rath 090399a308 chore: update version to 1.6.3 2026-05-08 21:10:21 +02:00
Linus Rath c31a58af1a i18n: add missing translation keys across 15 locales 2026-05-08 21:07:14 +02:00
Linus Rath 65aabb943c fix: fall back when only truncation indicator remains in email preview 2026-05-08 20:29:16 +02:00
Linus Rath 9c8739c4bb fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:27:03 +02:00
Linus Rath 48f72be209 fix: preserve emoji colors in dark mode email viewer 2026-05-08 20:26:17 +02:00
Linus Rath 92fb0c63e9 fix: trim leading whitespace from email list preview 2026-05-08 20:19:25 +02:00
Linus Rath 55596556ef feat: import .eml files via folder right-click menu 2026-05-08 20:12:33 +02:00
Linus Rath abd63d124f fix: add benchmark directory to ESLint ignore list 2026-05-08 19:56:21 +02:00
Linus Rath 562080b7a3 fix: request shareWith explicitly so calendar/address book shares survive a re-login #257 2026-05-08 19:53:32 +02:00
Linus Rath 41c9f4926c fix: prevent white-on-white in dark mode for nested bgcolor containers 2026-05-08 02:49:58 +02:00
Linus Rath e7e78072d4 fix: render plain-text-only emails as text, not HTML 2026-05-07 17:49:39 +02:00
Linus Rath cd363b4840 fix: strip leading punctuation when computing avatar initials 2026-05-07 17:43:02 +02:00
Linus Rath 3a350c14a6 fix: hide files/contacts nav items when JMAP server lacks support 2026-05-07 17:21:47 +02:00
Linus Rath b0765bf085 style: redesign quick reply to match sender/banner layout 2026-05-07 17:02:06 +02:00
Linus Rath 9225ba0790 fix: render HTML-only emails and redesign external content prompt 2026-05-07 16:10:26 +02:00
Linus Rath 3b36738192 fix: pad Word/Outlook HTML email rendering 2026-05-07 15:42:47 +02:00
Linus Rath 3edd35ab57 fix: hide email hover actions on mobile 2026-05-07 15:23:05 +02:00
Linus Rath a86a96e390 fix: disable StarterKit's bundled link/underline to avoid duplicate extensions 2026-05-07 12:33:51 +02:00
Linus Rath 5f464d4ee2 feat: lift 5-account cap on HTTP/2 2026-05-07 12:28:33 +02:00
Linus Rath bd72dec98f chore: update version to 1.6.2 2026-05-06 20:11:10 +02:00
Linus Rath 1331a3767c Revert "chore: update version to 1.6.1"
This reverts commit cb200330e7.
2026-05-06 20:10:08 +02:00
Linus Rath cb200330e7 chore: update version to 1.6.1 2026-05-06 20:09:08 +02:00
Linus Rath 178922323d chore: update version to 1.6.2 2026-05-06 20:07:36 +02:00
Linus Rath 91cf125a5d chore: remove experimental tags from themes, plugins, and sender favicons settings 2026-05-06 20:03:19 +02:00
Linus Rath f4b7ef8117 i18n: add missing translation keys across 15 locales 2026-05-06 19:59:14 +02:00
Linus Rath d175fc2983 feat: restrict plugin and theme install/uninstall to admin dashboard 2026-05-06 19:34:21 +02:00
Linus Rath 2f8bbdc636 fix: open plugin card on first click of a setting sub-result 2026-05-06 19:18:07 +02:00
Linus Rath b1573aada1 feat: surface plugin settings as search sub-results 2026-05-06 19:14:47 +02:00
Linus Rath 1cd6cde77c fix: highlight plugin and theme cards in settings search 2026-05-06 19:10:12 +02:00
Linus Rath 802a30508a fix: drop ghost sub-results from account and language search 2026-05-06 19:06:59 +02:00
Linus Rath 70c1ddd48c fix: improve settings search highlight 2026-05-06 18:58:06 +02:00
Linus Rath 2dc8537780 feat: add sub-result rows and highlight to settings search 2026-05-06 18:36:00 +02:00
Linus Rath 128d7d0401 feat: add fulltext search to settings sidebar 2026-05-06 18:29:02 +02:00
Linus Rath d3d79be64c feat: multi-server JMAP support 2026-05-06 17:33:55 +02:00
Linus Rath 43475945bf fix: update comment for external-mail warning 2026-05-06 01:54:28 +02:00
Linus Rath 09302684da fix: propagate settingsSchema 2026-05-06 01:48:49 +02:00
Linus Rath e68fcb4aec feat: redesign external banner above attachments 2026-05-06 01:37:29 +02:00
Linus Rath ded13f02cc fix: show notification banners above attachments 2026-05-06 01:14:01 +02:00
Linus Rath cfdee5e5c1 refactor: rework S/MIME banner to match calendar invitation 2026-05-06 01:05:40 +02:00
Linus Rath e7648eb1ac fix: close PDF preview on Escape before email viewer 2026-05-06 00:58:43 +02:00
Linus Rath e7be3d1e0c fix: render PDF previews via <object> with blob: in object-src CSP #253 2026-05-06 00:54:46 +02:00
Linus Rath 9639a6bb75 feat: expand calendar invitation banner on row click 2026-05-06 00:51:16 +02:00
Linus Rath 904a62ce79 feat: make calendar invitation banner collapsible 2026-05-06 00:49:09 +02:00
Linus Rath 2903e56cf6 fix: align calendar invitation icon with sender avatar column 2026-05-06 00:45:37 +02:00
Linus Rath b5e0189938 fix: adjust toast item border radius and progress bar styles 2026-05-05 21:57:56 +02:00
Linus Rath a44bd7c3e6 fix: add missing body type assertion in createPluginAPI fetch options 2026-05-05 21:52:58 +02:00
Linus Rath 0885d3c13e feat: http:fetch permission + httpOrigins manifest field 2026-05-05 21:50:04 +02:00
Linus Rath ef8eb1d73b fix: read activeAccountId from authStore in account selectors 2026-05-05 21:20:45 +02:00
Linus Rath 9f67bc078a feat: ingest icon/banner/screenshots from source repo 2026-05-05 21:12:23 +02:00
Linus Rath da411af6d3 feat: project EmailReadView for email-banner slot, expose auth results 2026-05-05 20:31:11 +02:00
Linus Rath 265908b05b fix: resolve PLUGIN_DEV_DIR plugins in admin config route 2026-05-05 20:17:45 +02:00
Linus Rath 2a769c2b0a feat: run onBeforeEmailSend hook before send, expose fromEmail on OutgoingEmail 2026-05-05 20:04:00 +02:00
Linus Rath 28054c81ea fix: inline plugin configure panel to avoid dev-mode hang 2026-05-05 19:04:29 +02:00
Linus Rath e7264f521c fix: collapse admin panel into single tabbed page 2026-05-05 18:50:57 +02:00
Linus Rath 1b0ca8967e feat: bundle plugin src/ on demand via esbuild 2026-05-05 18:35:22 +02:00
Linus Rath 3e336d459c feat: plugin hot-reload + dev-folder loading 2026-05-05 18:05:17 +02:00
Linus Rath 94f55afd1f fix: remove fly-in animation from context menu submenus 2026-05-05 17:21:46 +02:00
Linus Rath 7b058ed0ac fix: calendar invitation picker clipping #250 2026-05-05 17:17:01 +02:00
Luis Felipe MarzagaoandLinus Rath 853b0eb855 fix: add missing Czech flag icon 2026-05-05 08:59:28 +02:00
Linus Rath 41a458d872 fix: add Grafana badge to README for dashboard access 2026-05-04 23:56:55 +02:00
Linus Rath d1c5dba7d7 chore: update version to 1.6.1 2026-05-04 12:34:53 +02:00
Linus Rath 8c50abe221 fix: synchronize mobile submenu view with browser history for better navigation 2026-05-04 12:31:51 +02:00
Linus Rath 07367a8a5d fix: update email viewer styles to improve overflow handling 2026-05-04 12:27:44 +02:00
Linus Rath 1a50788c91 fix: ensure cookieSlot consistency during account updates in auth store 2026-05-04 12:09:58 +02:00
Linus Rath 0e06bfe273 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-04 11:25:49 +02:00
Linus Rath f68e41d81a fix: enhance sharing functionality by renaming state 2026-05-04 11:24:05 +02:00
MaxwellandLinus Rath 8b164c556e fix: thread per-account cookie slot through OAuth flows
The multi-account refresh-token cookie slot wiring was half-implemented:
every account's refresh token ended up on slot 0, so "+ Add Account"
silently clobbered the previous account's `jmap_rt` cookie. On page
refresh, only the most-recently-added account had a working refresh
token; the others bounced to login.

Three coordinated changes:

1. `app/[locale]/login/page.tsx` (handleOAuthLogin): write the next-free
   cookie slot to `sessionStorage['oauth_cookie_slot']` before redirecting
   to the IdP. `loginWithOAuth` already reads this key but it was never
   written, so it always defaulted to 0.

2. `stores/auth-store.ts` (loginWithOAuth): distinguish "no value set"
   (`rawSlot === null`) from "value is 0". Previously
   `parseInt(getItem(...) || '0')` collapsed both cases, making the
   `getNextCookieSlot()` fallback unreachable.

3. `stores/auth-store.ts` (loginWithServerSso) +
   `app/api/auth/sso/complete/route.ts`: pass the slot through the body of
   the POST and use it for `refreshTokenCookieName(slot)`. Same pattern as
   the existing `/api/auth/token POST` that already accepts a slot. The
   server defaults to 0 for back-compat with any caller that omits it.

After the fix, signing in with multiple accounts produces distinct
`jmap_rt`, `jmap_rt_1`, `jmap_rt_2`, ... cookies (matching the cookieSlot
field in account-store) and all accounts survive a page refresh.

Repro before the fix:
- Sign in with one account, refresh — works.
- Click "+ Add Account", sign in with a second account, refresh — second
  account vanishes from the dropdown; switching to the first account in
  the dropdown still shows the second account's identity in the From box.
2026-05-04 11:22:45 +02:00
Linus Rath 2e1f53c899 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-03 20:07:20 +02:00
Linus Rath a6d2efaf74 feat: sanitize identity display name to prevent invalid From headers 2026-05-03 20:06:41 +02:00
Luis Felipe MarzagaoandLinus Rath 01cd9644ed i18n: update mailbox context menu across 12 locales 2026-05-03 11:01:39 +02:00
Linus Rath 1521826d37 feat: add functionality to automatically add recipients to trusted senders when replying 2026-05-02 23:50:50 +02:00
Linus Rath 0d218d0d2a fix: square the colored left marker on calendar events 2026-05-02 23:41:08 +02:00
Linus Rath 9777dd655c feat: add share indicators for calendars and contacts, update JMAP capabilities #244 2026-05-02 23:29:21 +02:00
Linus Rath f970fd1822 feat: add plugin hooks for compose, attachments, search, lifecycle, and routing 2026-05-02 21:27:56 +02:00
Linus Rath 5e096240b3 feat: refresh update status on every dev reload 2026-05-02 13:23:58 +02:00
Linus Rath bc97a1ac10 feat: make update notice non-dismissible 2026-05-02 13:07:34 +02:00
Linus Rath 4594fb2572 revert: restore VERSION to correct value 2026-05-02 01:59:27 +02:00
Linus Rath 5319562c94 feat: add update-available detection 2026-05-02 01:58:30 +02:00
Linus Rath 599fa66822 fix: show git commit in About instead of "unknown" 2026-05-02 00:28:08 +02:00
Linus Rath 8041700668 chore: update version to 1.6.0 2026-05-01 22:02:57 +02:00
Linus Rath bade68a8b8 i18n: add missing email viewer detail and authentication translations 2026-05-01 22:02:14 +02:00
Linus Rath 4be7176802 chore: update version to 1.6.0 2026-05-01 21:56:53 +02:00
Linus Rath 8813533958 fix: respect per-email dark mode toggle when always-light setting is on 2026-05-01 21:42:02 +02:00
Linus Rath affa239d75 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-05-01 21:35:53 +02:00
Linus Rath 5d292fa43f fix: scroll apps list in navigation rail to prevent overflow 2026-05-01 21:35:29 +02:00
Linus Rath 878df6bb49 refactor: enhance path rendering in mailbox context menu 2026-05-01 21:26:03 +02:00
Linus Rath 607a9584fd refactor: implement path shortening for mailbox context menu 2026-05-01 21:25:31 +02:00
Linus Rath b8e2bfd793 fix: show full path in mailbox context menu header 2026-05-01 21:15:47 +02:00
Linus Rath 4ad6d37877 fix: clamp context submenu inside viewport 2026-05-01 21:03:54 +02:00
Linus Rath d7c29b7bec refactor: rework mobile mail viewer toolbar 2026-05-01 21:00:34 +02:00
Luis Felipe MarzagaoandLinus Rath b86bc541ab i18n: add missing keys accross 14 locales 2026-05-01 20:37:51 +02:00
Luis Felipe MarzagaoandLinus Rath 8c74e01a40 fix: add useTranslations for "selected emails" and "cancel" on email list batch operations 2026-05-01 20:37:51 +02:00
Linus Rath 231a9017d2 refactor: make settings panel mobile friendly 2026-05-01 20:30:15 +02:00
Linus Rath 9a5bb78b18 refactor: make admin panel mobile friendly 2026-05-01 20:22:29 +02:00
Linus Rath eecf16daa2 fix: stop silently destroying emails when trash mailbox isnt found #195 2026-05-01 20:01:13 +02:00
Vadim BelovandLinus Rath 210150a02e Fix push preview JMAP query
Resolve the Inbox mailbox id before running Email/query.

The previous query passed a JMAP result reference object directly into the inMailbox filter, which can make the preview endpoint return 502 and cause push notifications to fall back to the generic “New mail” text.
2026-05-01 19:53:25 +02:00
Linus Rath e50691d6c4 fix: navigate tour to mailbox when starting from another page 2026-05-01 18:30:51 +02:00
Linus Rath 089963b1b3 refactor: redesign expanded details panel 2026-05-01 18:22:06 +02:00
Linus Rath 4af952613a fix: prevent context menu jump and animation on open 2026-05-01 17:43:07 +02:00
Linus Rath 0d9fa0285f fix: prevent context menu from clipping below viewport 2026-05-01 17:28:40 +02:00
Linus Rath 683fe75864 i18n: translate SPF/DKIM/DMARC tooltips 2026-05-01 17:25:52 +02:00
Linus Rath e9c9be84ad fix: preserve list scroll position when tagging an email 2026-05-01 17:18:11 +02:00
Linus Rath 7822a363dd fix: render below-header overflow popup outside clipped row 2026-05-01 17:10:17 +02:00
Linus Rath 1e535e96a2 feat: image attachment thumbnails and preview chips 2026-05-01 17:03:45 +02:00
Linus Rath 32135ddb95 fix: collapse below-header attachments to single row with overflow pill 2026-05-01 16:29:38 +02:00
Linus Rath 841513e510 feat: support subpath deployment with NEXT_PUBLIC_BASE_PATH environment variable 2026-05-01 14:57:49 +02:00
Linus RathandGitHub 5964b2e456 Update README 2026-05-01 10:06:49 +02:00
325 changed files with 32750 additions and 6619 deletions
+21 -4
View File
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Admin Dashboard Data # Admin Dashboard Data
# ============================================================================= # =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash, # Admin data is split across two directories so the config volume can be
# installed plugins/themes, and audit logs (default: ./data/admin). # mounted read-only after the setup wizard completes (see issue #226).
# For Docker, the default resolves to /app/data/admin - mount a persistent #
# volume there (see docker-compose.yml). # Config dir - operator-authored state. Holds config.json, policy.json,
# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
# branding uploads. Safe to mount read-only after setup.
# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
# ADMIN_CONFIG_DIR=./data/admin
#
# State dir - runtime mutations. Holds admin-state.json (login timestamps),
# audit.log, and the bootstrap setup token. Always read-write.
# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
# is set, for back-compat with single-volume installs)
# ADMIN_STATE_DIR=./data/admin-state
#
# Set to "true" to enforce read-only mode at the application layer (cleaner
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
# ADMIN_CONFIG_READONLY=true
#
# Legacy: a single dir containing both config and state. Honoured if neither
# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin # ADMIN_DATA_DIR=./data/admin
# ============================================================================= # =============================================================================
@@ -50,6 +50,8 @@ jobs:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+2
View File
@@ -78,6 +78,8 @@ jobs:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ github.sha }}
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+326
View File
@@ -1,5 +1,331 @@
# Changelog # Changelog
## 1.7.0 (2026-05-21)
> **New: Pro mode (experimental).** Opt-in tabbed multi-pane interface for power users. Open multiple mail, calendar, contacts, and file views side-by-side, drag tabs to reorder or split panes at the edges, and work across all logged-in accounts in one shell - cross-account email moves, a unified inbox with search, account-split calendar/contacts/files sidebars, and a per-account "From" dropdown in the composer. Enable from Settings → Appearance; the `proInterface` preference is per-device and not synced.
### Breaking Changes
- **Plugins**: Plugins now run inside a null-origin iframe sandbox and talk to the host over a postMessage RPC bridge. The in-process plugin runtime is gone; the bundled in-tree plugins have been migrated. Third-party plugins built against the old in-process API need to be ported to the sandboxed runtime.
- **Plugins**: Server-managed bundles must be Ed25519-signed by the host and approved by an admin before they load. The host public key is served from `/api/plugin-signing-pubkey` and each bundle response carries the signature in the `X-Bundle-Signature` header. User-uploaded bundles still load unsigned, but managed marketplace and dev-folder bundles do not.
- **Plugins**: `bundleHash` is now a full SHA-256 over the bundle. Legacy short hashes are migrated on first load; any out-of-band tooling that pinned the old hash format needs to be updated.
### Features
- **Pro**: Tabbed shell with drag-to-reorder, drag-to-edge to split, side-by-side panes, and pane-aware responsive layout with a scoped sidebar overlay
- **Pro**: Auto-redirect to the Pro shell when Pro mode is on; `proInterface` is kept per-device instead of syncing
- **Pro**: Multi-account mail sidebar with client routing and a per-account mailbox cache
- **Pro**: Unified mailbox always visible, with full-text search
- **Pro**: Cross-account email moves
- **Pro**: Multi-account calendar sidebar split into owned vs shared per account
- **Pro**: Multi-account contacts and a cross-account file picker
- **Pro**: Composer From dropdown grouped by account
- **Plugins**: Per-plugin admin approval workflow with Ed25519 bundle signing verified on load
- **Setup**: Allow the setup wizard over plain HTTP with a dismissable warning gate
- **Setup**: Warn when the JMAP URL points at a local-only host
- **Account**: List and reorder logged-in accounts from settings (#282)
- **Mail**: Mobile handoff page with JMAP authentication verification for cross-device OAuth
- **Mail**: Pluggable reply/forward quote header (#295)
- **Calendar**: Support multiple flexible event reminders (#170)
- **Admin**: Expose PWA, app identity, and extension directory keys in the JSON config (#312)
- **Admin**: Surface OAuth scope settings and wire up orphaned admin policy gates
### Security
- **Plugins**: Pin parent origin in the iframe bridge to block cross-frame postMessage
- **Plugins**: Ignore plugin-supplied `target` in `ui.openExternalUrl` to block host-frame hijack
- **Plugins**: Validate plugin/theme id in marketplace install to block path traversal
- **Plugins**: Prevent plugin config from leaking to non-admin users
- **Admin**: Gate admin routes against cross-origin CSRF
- **Auth**: Bind Stalwart auth context to the credential, not the cookie-claimed username
- **Auth**: Validate OAuth discovery endpoints against SSRF
- **Mail**: Tighten HTML sanitization at plain-text email, signature, and i18n render sites
- **Mail**: Block script-bearing MIME types from inline attachment preview
- **Mail**: Escape print-window fields and re-sanitize body to block XSS
- **S/MIME**: Stop persisting passphrases in `sessionStorage`
- **API**: Correct regex for valid API POST path validation
### Fixes
- **Mail**: Serialize draft autosave with send to stop replies stalling in Drafts (#303)
- **Mail**: Omit empty cc/bcc from `Email/set` so the server does not emit a bare `Cc:` header (#301)
- **Mobile**: Allow adding contacts from the mail recipient popover (#306)
- **Mobile**: Prevent dual-scroll and use full width for mail content
- **Mobile**: OAuth handoff flow
- **Calendar**: Scope iCal subscriptions per JMAP account; fix refresh and clear
- **Calendar**: iCal subscription refresh, rollback, and URL normalization
- **Calendar**: Show avatars in the calendar/address book sharing menu
- **Contacts**: Normalize malformed contact photo data URIs (#307)
- **Identity**: Clear identity signature fields when emptied
- **Identity**: Show size cap on identity signature fields
- **Identity**: Allow table-based layouts in the HTML signature sanitizer
- **Plugins**: Load `globals.css` and Geist font in the plugin sandbox iframe
- **Plugins**: Sync plugin slot iframe height with reported content height
- **Plugins**: Use plugin slot offer snapshots for `useSyncExternalStore`
- **Filters**: Prevent duplication of Bulwark rules with literal braces in values
- **Setup**: Defer setup wizard HTTP detection to avoid hydration mismatch
- **Routing**: Anchor unmatched URLs into `main` so 404 renders
- **Routing**: Respect server-resolved locale on first visit (#309)
- **Routing**: Split app into `(main)`/`(sandbox)` route groups so the plugin iframe hydrates properly
- **Files**: Stop parent directory navigation from jumping to root
- **Build**: Stop pulling `node:dns` into the client bundle via OAuth discovery
- **UI**: Toggle recipient popover when clicking the name again
- **UI**: Remove white halo around photo avatars
### i18n
- Add missing translation keys across 16 locales
## 1.6.7 (2026-05-17)
### Features
- **Contacts**: vCard 4.0 parsing and generation support
- **Admin**: Master-user impersonation route with `app-top-banner` plugin slot rendered on every authenticated page
- **Admin**: Allow admin password overwrite during setup recovery
- **Setup**: HTTPS requirement warning in the setup wizard
- **Mobile**: Show details toggle and expandable panel for sender info
### Performance
- **Calendar**: Speed up calendar invitation banner load
### Security
- **Mail**: Sandbox thread email HTML in `srcDoc` iframe with a CSP `<meta>` tag
- **Admin**: Redact sensitive config secrets from the admin API response
- **Admin**: Make impersonation cookies session-only
### Fixes
- **Auth**: Read `OAUTH_SCOPES` at runtime instead of build time
- **Auth**: Use a relative `Location` header in redirects
- **Auth**: Adopt orphan session cookie on first SPA load
- **Mail**: Per-account push subscriptions so multi-account notifications work (#298)
- **Mail**: Close attachment preview when clicking outside the content area
- **Mail**: Pin quick reply to the bottom for short emails
- **Mail**: Show "no body content" instead of an infinite skeleton for bodyless emails
- **Mail**: Show contact popup when clicking the sender name in the email header
- **Mail**: Prevent long addresses from overflowing email details columns (#297)
- **Mobile**: Align quick reply with the mobile bottom toolbar
- **Mobile**: Respect safe-area insets on mobile bottom bars
- **Mobile**: Pad `safe-area-inset-top`
- **UI**: Apply dark background to the email content wrapper in dark mode
- **UI**: Improve dark mode background colors in the email viewer
- **UI**: Add viewport export with `initialScale: 1`
- **UI**: Strip the Stalwart master-user `%` suffix from the displayed account
- **Plugins**: Warn and block install when the app version is below the plugin's `minAppVersion`
- **Plugins**: Register `app-top-banner` in plugin-store `SLOT_NAMES`
- **Plugins**: Carry `configSchema` + `settingsSchema` through marketplace install
- **Build**: Add `outputFileTracingExcludes` to reduce Turbopack memory tracing
### i18n
- Add missing translation keys across 16 locales
## 1.6.6 (2026-05-15)
### Features
- **Mail**: Sync onboarding completion state across devices so the welcome flow only runs once per account (#285)
- **Mail**: Distinct icons for Shared, Important, Memos, Scheduled, and Snoozed folders (#288)
- **Compose**: Raise HTML identity signature length cap to 50,000 characters
- **Compose**: Allow `<img>` tags in HTML identity signatures for inline logos and banners
### Fixes
- **Files**: Hide Files settings entry and sidebar nav when the `filesEnabled` policy is off (#291)
- **Admin**: Honor the `cookieSameSite` admin config override instead of always defaulting (#284)
- **UI**: Standardize punctuation in tooltips and inline comments across locales
### i18n
- Add Danish localization
- Clean up Danish locale wiring and sort the language picker alphabetically (#286)
## 1.6.5 (2026-05-13)
### Features
- **Protocol**: Register as the system handler for `mailto:` and `webcal:` links from a new protocol handler settings page
- **Protocol**: Account picker for protocol links when multiple accounts are connected
- **Protocol**: Import-or-subscribe choice for detected webcal calendars
- **Protocol**: Reuse the open PWA/session for `mailto:` links instead of always opening a new tab
- **UI**: Route account avatars through the shared `Avatar` component for consistent fallbacks (#278)
### Fixes
- **Calendar**: Support HTTP basic auth in iCal subscription URLs (#275)
- **Admin**: Honor admin-uploaded favicon in root metadata (#274)
- **Admin**: Honor `NEXT_PUBLIC_BASE_PATH` in admin sidebar nav links (#271)
- **UI**: Broaden body font stack so Thai (and other non-Latin scripts) render correctly in subjects, sender names, and other chrome (#265)
## 1.6.4 (2026-05-11)
### Web Setup Wizard
First-launch web setup wizard. New installs no longer need to hand-edit `.env.local` - point a browser at the container and the wizard probes the JMAP server(s), configures OAuth/OIDC, generates the session secret, accepts branding uploads, and provisions the initial admin password. Admin storage is now split into `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps); the legacy `ADMIN_DATA_DIR` keeps working for existing installs.
### Features
- **Setup**: Web setup wizard with multi-step flow: Server, Auth, Security, Logging, Branding, Review, Admin
- **Setup**: Admin config/state directory split with optional `ADMIN_CONFIG_READONLY` for immutable deployments (#226)
- **Setup**: File uploads on the wizard branding step
- **Setup**: Redesigned review step with grouped summary and an advanced toggle for the full config
- **Setup**: Require explicit confirmation when JMAP probe finds no session
- **Mail**: Drag attachments out of the viewer to the local file system (#267)
- **Mail**: Reading Pane at Bottom mail layout (#262)
- **Mail**: Configurable signature position - above or below quoted text (#266)
- **Mail**: Signature position is now searchable from the email behavior settings
- **Mail**: Show avatar in Focused list for compact density and above
- **Mail**: Align Focused list preview with other layout previews
- **Compose**: From-header override in the composer with catch-all auto-reply, replies to an alias on a domain you own pre-fill the alias as the sender even when it isn't a configured identity (#246)
### Performance
- **Mail**: Prefetch initial email data on login
- **Auth**: Parallelize login round-trips and drop redundant JMAP re-verify
### Fixes
- **Auth**: Skip upstream JMAP reverify for trusted URLs (#237)
- **Auth**: Show account identity in the switcher header instead of the sending alias
- **Compose**: Fall back to the primary identity signature on reply
- **Setup**: Drop redundant first-login banner about removing `ADMIN_PASSWORD` (#222)
- **UI**: Consistent notice cards for server probe results
### i18n
- Add missing translation keys across 15 locales
## 1.6.3 (2026-05-08)
### Features
- **Mail**: Lift 5-account cap on HTTP/2
- **Mail**: Import `.eml` files via folder right-click menu
### Fixes
- **Mail**: Trim leading whitespace from email list preview
- **Mail**: Fall back when only the truncation indicator remains in email preview
- **Mail**: Hide files/contacts nav items when JMAP server lacks support
- **Viewer**: Preserve emoji colors in dark mode
- **Viewer**: Prevent white-on-white in dark mode for nested `bgcolor` containers
- **Viewer**: Render plain-text-only emails as text, not HTML
- **Viewer**: Render HTML-only emails and redesign external content prompt
- **Viewer**: Pad Word/Outlook HTML email rendering
- **Compose**: Redesign quick reply to match sender/banner layout
- **Compose**: Disable StarterKit's bundled link/underline to avoid duplicate extensions
- **Sharing**: Request `shareWith` explicitly so calendar/address book shares survive a re-login (#257)
- **UI**: Strip leading punctuation when computing avatar initials
- **Mobile**: Hide email hover actions
### i18n
- Add missing translation keys across 15 locales
## 1.6.2 (2026-05-06)
### Features
- **Plugins**: Hot-reload and dev-folder loading for live plugin development
- **Plugins**: On-demand `src/` bundling via esbuild
- **Plugins**: New `http:fetch` permission and `httpOrigins` manifest field
- **Plugins**: `onBeforeEmailSend` hook with `fromEmail` exposed on `OutgoingEmail`
- **Plugins**: Project `EmailReadView` for the email-banner slot and expose auth results
- **Plugins**: Ingest icon, banner, and screenshots from the source repo
- **Plugins**: Restrict plugin and theme install/uninstall to the admin dashboard
- **Mail**: Multi-server JMAP support
- **Settings**: Fulltext search across the settings sidebar
- **Settings**: Sub-result rows with highlight in settings search
- **Settings**: Surface plugin settings as search sub-results
- **Settings**: Remove experimental tags from themes, plugins, and sender favicons
- **Viewer**: Redesigned external-mail banner above attachments
- **Calendar**: Calendar invitation banner expands on row click
- **Calendar**: Calendar invitation banner is now collapsible
### Fixes
- **Admin**: Collapse admin panel into a single tabbed page
- **Plugins**: Inline plugin configure panel to avoid dev-mode hang
- **Plugins**: Resolve `PLUGIN_DEV_DIR` plugins in admin config route
- **Plugins**: Add missing body type assertion in `createPluginAPI` fetch options
- **Plugins**: Propagate `settingsSchema`
- **Settings**: Highlight plugin and theme cards in search results
- **Settings**: Open plugin card on first click of a setting sub-result
- **Settings**: Drop ghost sub-results from account and language search
- **Settings**: Improve search highlight styling
- **Viewer**: Show notification banners above attachments
- **Viewer**: Rework S/MIME banner to match calendar invitation
- **Viewer**: Close PDF preview on Escape before email viewer
- **Viewer**: Render PDF previews via `<object>` with `blob:` in object-src CSP (#253)
- **Calendar**: Align invitation icon with sender avatar column
- **Calendar**: Fix invitation picker clipping (#250)
- **Auth**: Read `activeAccountId` from authStore in account selectors
- **UI**: Adjust toast item border radius and progress bar styles
- **UI**: Remove fly-in animation from context menu submenus
- **i18n**: Add missing Czech flag icon
### i18n
- Add missing translation keys across 15 locales
## 1.6.1 (2026-05-04)
### Features
- **Updates**: Update-available detection with non-dismissible notice and dev-reload refresh
- **Plugins**: New plugin hooks for compose, attachments, search, lifecycle, and routing
- **Sharing**: Share indicators for calendars and contacts, updated JMAP capabilities (#244)
- **Mail**: Auto-add recipients to trusted senders when replying
- **Identity**: Sanitize identity display name to prevent invalid `From` headers
### Fixes
- **Mobile**: Synchronize mobile submenu view with browser history for better navigation
- **Viewer**: Update email viewer styles to improve overflow handling
- **Auth**: Ensure `cookieSlot` consistency during account updates in auth store
- **Auth**: Thread per-account cookie slot through OAuth flows
- **Calendar**: Square the colored left marker on calendar events
- **About**: Show git commit in About instead of "unknown"
### i18n
- Update mailbox context menu translations across 12 locales
## 1.6.0 (2026-05-01)
### Features
- **Deployment**: Subpath deployment support via `NEXT_PUBLIC_BASE_PATH` environment variable
- **Mail**: Image attachment thumbnails and preview chips
- **Mobile**: Reworked mobile mail viewer toolbar
- **Mobile**: Mobile-friendly settings panel
- **Mobile**: Mobile-friendly admin panel
- **Mail**: Redesigned expanded details panel
- **Mailbox**: Show full path in mailbox context menu header with intelligent path shortening
### Fixes
- **Viewer**: Respect per-email dark mode toggle when "always show in light mode" is on
- **Navigation**: Scroll apps list in navigation rail to prevent overflow
- **Context menu**: Clamp submenu inside viewport
- **Context menu**: Prevent context menu from clipping below viewport
- **Context menu**: Prevent jump and animation on open
- **Mail**: Stop silently destroying emails when trash mailbox isn't found (#195)
- **Mail**: Preserve list scroll position when tagging an email
- **Mail**: Render below-header overflow popup outside clipped row
- **Mail**: Collapse below-header attachments to single row with overflow pill
- **Push**: Fix push preview JMAP query
- **Tour**: Navigate tour to mailbox when starting from another page
- **i18n**: Add `useTranslations` for "selected emails" and "cancel" on email list batch operations
### i18n
- Translate SPF/DKIM/DMARC tooltips
- Add missing keys across 14 locales
## 1.5.4 (2026-05-01) ## 1.5.4 (2026-05-01)
### Features ### Features
+26 -34
View File
@@ -10,14 +10,17 @@
# Contributing to Bulwark Webmail # Contributing to Bulwark Webmail
Thank you for your interest in contributing to Bulwark Webmail! This document provides guidelines and information for contributors. We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
## Join our Community If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
**New to the project or looking for a place to start?** You don't need to be an expert to contribute! Whether you need help setting up your environment, want to report a bug, or are interested in helping with translations, our Discord is the best place to connect.
* **Get Support:** Get real-time help with development hurdles. ## Join the Community
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
* **Collaborate:** Meet the team and other contributors working to make Bulwark better. You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
- **Get support** - real-time help with development hurdles
- **Share ideas** - feature suggestions, design feedback, doc improvements
- **Collaborate** - meet the team and other contributors
[**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT) [**Join the Bulwark Discord Server**](https://discord.gg/tYCujymGrT)
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
## Internationalization (i18n) ## Internationalization (i18n)
This project uses **next-intl** for internationalization. Please follow these guidelines: This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
### Key Rules ### Rules
1. **Never hardcode user-facing text** - Always use translations: 1. **Never hardcode user-facing text** - always use translations:
```tsx ```tsx
const t = useTranslations("namespace"); const t = useTranslations("namespace");
return <div>{t("key")}</div>; return <div>{t("key")}</div>;
``` ```
2. **Translation file locations**: 2. **Add new keys to `en/common.json` first.** Other locales can follow in the same PR or a follow-up - missing keys fall back to English.
- English: `/locales/en/common.json`
- French: `/locales/fr/common.json`
3. **Namespace organization**: 3. **Namespace organization**:
- `login.*` - Login page strings - `login.*` - login page
- `sidebar.*` - Sidebar navigation - `sidebar.*` - sidebar navigation
- `email_list.*` - Email list component - `email_list.*` - email list
- `email_viewer.*` - Email viewer component - `email_viewer.*` - email viewer
- `email_composer.*` - Email composer - `email_composer.*` - composer
- `common.*` - Shared strings - `settings.*` - settings page
- `notifications.*` - Toast/alert messages - `notifications.*` - toasts and alerts
- `settings.*` - Settings page - `common.*` - shared strings
4. **Adding new strings**: 4. **Locale-aware navigation**:
- Add to **both** English and French translation files
- Use descriptive, hierarchical keys
- Keep translations consistent in tone
5. **Locale-aware navigation**:
```tsx ```tsx
router.push(`/${params.locale}/settings`); router.push(`/${params.locale}/settings`);
``` ```
@@ -203,16 +200,11 @@ webmail/
## Security ## Security
- **Never commit sensitive data** (API keys, passwords, etc.) - **Never commit secrets** - API keys, passwords, tokens, `.env*` files
- **Sanitize user input** and email content - **Sanitize user input** and email content
- **Block external content** by default for privacy - **Block external content** by default - privacy is the point
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems) - **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
## Questions? ## Questions?
If you have questions about contributing, feel free to: Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
- Open an issue for discussion
- Check existing issues and pull requests
Thank you for helping improve Bulwark Webmail!
+9 -1
View File
@@ -4,6 +4,14 @@ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Optional: serve under a subpath like /webmail. Baked into emitted asset URLs
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown
ENV GIT_COMMIT=$GIT_COMMIT
RUN npx next build --webpack RUN npx next build --webpack
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
@@ -26,7 +34,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+24 -13
View File
@@ -2,19 +2,23 @@
## Mail ## Mail
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding) - Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle - Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts - Unified mailbox view across all connected accounts
- Draft auto-save with identity preservation - Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Attachment upload, download, and inline preview; forgotten-attachment warning - Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview; image thumbnails and forgotten-attachment warning
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries - Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
- Batch operations multi-select, archive, delete, move, tag - Batch operations multi-select, archive, delete, move, tag
- Archive modes direct, by year, or by month - Archive modes direct, by year, or by month
- Multi-tag support with color labels, reordering, and drag-and-drop assignment - Multi-tag support with color labels, reordering, and drag-and-drop assignment
- Star/unstar with configurable mark-as-read delay - Star/unstar with configurable mark-as-read delay
- Virtual scrolling for large mailboxes - Virtual scrolling for large mailboxes plus prefetching of initial email data on login
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers - Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
- Plain-text composer mode and Reply-To support - Plain-text composer mode and Reply-To support
- Configurable signature position (above or below quoted text) per identity
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
- `.eml` file import via folder right-click menu
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping - TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
- Folder management with icon picker, subfolders, and sidebar counts - Folder management with icon picker, subfolders, and sidebar counts
- Print directly from the viewer - Print directly from the viewer
@@ -79,7 +83,7 @@
## Interface ## Interface
- Three-pane layout with resizable columns - Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
- Dark and light themes with intelligent email color transformation - Dark and light themes with intelligent email color transformation
- Responsive desktop, tablet, and mobile layouts - Responsive desktop, tablet, and mobile layouts
- Full keyboard navigation - Full keyboard navigation
@@ -94,31 +98,38 @@
## Internationalization ## Internationalization
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська 17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
## Identity & Multi-Account ## Identity & Multi-Account
- Up to 5 simultaneous accounts with instant switching and per-account session persistence - Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
- Account switcher with connection status and default account selection - Account switcher with connection status and default account selection
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list - Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions - Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts - Shared folders across accounts
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) - Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
## Admin & Extensibility ## Admin & Extensibility
- Stalwart admin dashboard with dedicated policy sections - Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement - Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- Plugin system schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs, and managed policy enforcement
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
- Themes upload, enforce, and manage admin-controlled themes as ZIP bundles - Themes upload, enforce, and manage admin-controlled themes as ZIP bundles
- Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) - Extension marketplace browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
- Bundled plugins including Jitsi Meet calendar integration - Bundled plugins including Jitsi Meet calendar integration
## Operations ## Operations
- Progressive Web App with service worker, install prompt, and dynamic manifest - Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
- Automatic update check with server-side logging of new releases - Automatic update check with server-side logging of new releases and a non-dismissible update notice
- Structured logging (`text` or `json`) with category-based levels - Structured logging (`text` or `json`) with category-based levels
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) version, platform, bucketed account counts, feature toggles only
- Release (`main`) and development (`dev`) Docker images on GHCR - Release (`main`) and development (`dev`) Docker images on GHCR
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
- Demo mode with fixture data no mail server required - Demo mode with fixture data no mail server required
+67 -15
View File
@@ -12,13 +12,37 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.5.4-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.7.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
</div> </div>
--- ---
## Installer
New in **1.6.4**: a web-based setup wizard runs on first launch no `.env.local` editing, no shelling into the container.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
</picture>
Point a browser at the running container and the wizard guides you through:
- **Server** probe one or more JMAP endpoints, optional auto-pick by email domain, Stalwart feature toggle
- **Auth** OAuth2 / OIDC discovery and validation, or basic-auth fallback
- **Security** generate or paste a `SESSION_SECRET`, opt into settings sync
- **Logging** text or JSON, level
- **Branding** upload favicon, app logos, login logos, and company / legal URLs
- **Review** grouped summary with an advanced toggle for the full config
- **Admin** set the initial admin password and optionally drop a `.config-locked` marker so the config volume can be remounted read-only
The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JMAP_SERVER_URL` in the environment skips the wizard and uses env-managed configuration instead.
---
## Screenshots ## Screenshots
<picture> <picture>
@@ -53,18 +77,16 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
</tr> </tr>
</table> </table>
> **Anonymous telemetry is on by default** since 1.5.3. Each instance sends a daily heartbeat (version, platform, bucketed account counts, feature toggles - no message data, no PII). Disable from **Admin → Telemetry**, by setting `BULWARK_TELEMETRY=off`, or by clearing the endpoint. Full schema: [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
## Overview ## Overview
Bulwark is a full webmail suite not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates - **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions - **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export - **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload - **Files** Stalwart's JMAP FileNode storage with previews and folder upload
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard. Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
Full feature list: **[FEATURES.md](FEATURES.md)**. Full feature list: **[FEATURES.md](FEATURES.md)**.
@@ -75,28 +97,25 @@ Full feature list: **[FEATURES.md](FEATURES.md)**.
### Docker ### Docker
```bash ```bash
docker run -d -p 3000:3000 \ docker run -d -p 3000:3000 ghcr.io/bulwarkmail/webmail:latest
-e JMAP_SERVER_URL=https://mail.example.com \
ghcr.io/bulwarkmail/webmail:latest
``` ```
Or with Docker Compose: Or with Docker Compose:
```bash ```bash
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
docker compose up -d docker compose up -d
``` ```
On first launch, open `http://localhost:3000` the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
### From Source ### From Source
```bash ```bash
git clone https://github.com/bulwarkmail/webmail.git git clone https://github.com/bulwarkmail/webmail.git
cd webmail cd webmail
npm install npm install
cp .env.example .env.local
# Edit .env.local set JMAP_SERVER_URL
npm run build && npm start npm run build && npm start
# Then open http://localhost:3000 to run the setup wizard
``` ```
### Development ### Development
@@ -109,13 +128,13 @@ npm run lint
## Configuration ## Configuration
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
```env ```env
# Required # Optional overrides whatever the wizard writes
JMAP_SERVER_URL=https://mail.example.com JMAP_SERVER_URL=https://mail.example.com
# Optional
APP_NAME=My Webmail APP_NAME=My Webmail
``` ```
@@ -219,6 +238,39 @@ LOG_LEVEL=info # error | warn | info | debug
</details> </details>
<details>
<summary>Admin data directories</summary>
```env
ADMIN_CONFIG_DIR=./data/admin # operator-authored: config.json, policy.json, plugins/, themes/
ADMIN_STATE_DIR=./data/admin-state # runtime: audit log, login timestamps, setup token
ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer
```
The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`.
</details>
<details>
<summary>Subpath / reverse proxy mount</summary>
To serve the webmail at a subpath (e.g. `https://example.com/webmail`):
```env
NEXT_PUBLIC_BASE_PATH=/webmail
NEXT_PUBLIC_LOCALE_PREFIX=always # avoids next-intl rewrite loops
```
Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** because Next.js bakes it into emitted asset URLs. To use it with the published Docker image, build your own image with the variable set:
```bash
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
```
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
</details>
## Keyboard Shortcuts ## Keyboard Shortcuts
| Key | Action | | Key | Action |
+1 -1
View File
@@ -1 +1 @@
1.5.4 1.7.0
+10
View File
@@ -0,0 +1,10 @@
import { notFound } from 'next/navigation';
// Catch-all that anchors unmatched URLs into the (main) route group so
// Next renders app/(main)/not-found.tsx (wrapped by (main)/layout.tsx)
// instead of the built-in __next_builtin__not-found page. Without this,
// route groups can't pick a root layout for URLs that match nothing, so
// 404s render bare.
export default function CatchAll() {
notFound();
}
@@ -43,6 +43,7 @@ function OAuthCallbackInner() {
const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
const serverUrl = sessionStorage.getItem("oauth_server_url"); const serverUrl = sessionStorage.getItem("oauth_server_url");
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
if (!codeVerifier || !serverUrl) { if (!codeVerifier || !serverUrl) {
setError("missing_params"); setError("missing_params");
@@ -52,12 +53,13 @@ function OAuthCallbackInner() {
const prefix = getPathPrefix(params.locale as string); const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
.then((success) => { .then((success) => {
if (success) { if (success) {
sessionStorage.removeItem("oauth_state"); sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url"); sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_server_id");
sessionStorage.removeItem("oauth_add_account_mode"); sessionStorage.removeItem("oauth_add_account_mode");
let redirectTo = `${prefix}/${params.locale}`; let redirectTo = `${prefix}/${params.locale}`;
try { try {
@@ -76,7 +78,69 @@ function OAuthCallbackInner() {
setError("token_exchange_failed"); setError("token_exchange_failed");
}); });
} else if (state) { } else if (state) {
// Server-side SSO flow - state was stored in encrypted httpOnly cookie // 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 mobileState: string | null = null;
try {
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
mobileState = sessionStorage.getItem("mobile_state");
} catch { /* sessionStorage may be unavailable */ }
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
// 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
// server's mobile-flow branch (keyed on the pending cookie) skips
// the refresh-token cookie write for the same reason.
(async () => {
try {
const res = await fetch("/api/auth/sso/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code, state }),
});
if (!res.ok) {
setError("token_exchange_failed");
return;
}
const data = await res.json();
const serverUrl = data.server_url as string | undefined;
const accessToken = data.access_token as string | undefined;
const tokenEndpoint = data.token_endpoint as string | undefined;
const clientId = data.client_id as string | undefined;
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
setError("token_exchange_failed");
return;
}
const fragment = new URLSearchParams({
flow: "oauth",
server_url: serverUrl,
access_token: accessToken,
token_endpoint: tokenEndpoint,
client_id: clientId,
state: mobileState ?? "",
});
if (typeof data.refresh_token === "string") {
fragment.set("refresh_token", data.refresh_token);
}
if (typeof data.expires_in === "number") {
fragment.set("expires_in", String(data.expires_in));
}
try {
sessionStorage.removeItem("mobile_redirect_uri");
sessionStorage.removeItem("mobile_state");
} catch { /* ignore */ }
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
} catch {
setError("token_exchange_failed");
}
})();
return;
}
const ssoPrefix = getPathPrefix(params.locale as string); const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state) loginWithServerSso(code, state)
.then((success) => { .then((success) => {
@@ -15,8 +15,9 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
@@ -31,17 +32,21 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { EventContextMenu } from "@/components/calendar/event-context-menu"; import { EventContextMenu } from "@/components/calendar/event-context-menu";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu"; import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { downloadEventICS } from "@/lib/calendar-ics-export"; import { downloadEventICS } from "@/lib/calendar-ics-export";
import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { getEventStartDate } from "@/lib/calendar-utils"; import { getEventStartDate } from "@/lib/calendar-utils";
@@ -56,6 +61,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal
import { getUserParticipantId } from "@/lib/calendar-participants"; import { getUserParticipantId } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
type PendingScopeAction = type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean } | { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
@@ -68,9 +75,17 @@ function isRecurringEvent(event: CalendarEvent): boolean {
export default function CalendarPage() { export default function CalendarPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
// collapses into a burger-toggled overlay instead of taking inline space.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { const {
@@ -96,6 +111,10 @@ export default function CalendarPage() {
const [showEventModal, setShowEventModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState<ParsedWebcal | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const [editingSubscription, setEditingSubscription] = useState<string | null>(null); const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null); const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined); const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
@@ -156,10 +175,10 @@ export default function CalendarPage() {
if (initialCheckDone && !isAuthenticated && !authLoading) { if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin(); redirectToLogin();
} else if (client && !supportsCalendar) { } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
router.push("/"); router.push("/");
} }
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@@ -167,12 +186,95 @@ export default function CalendarPage() {
} }
}, [error]); }, [error]);
const getWebcalProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) => {
if (!account.isConnected) return false;
return connectedClients.get(account.id)?.supportsCalendars() === true;
});
}, []);
const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
await switchAccount(accountId);
}
setPendingWebcalAccountChoice(null);
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
} finally {
setIsProtocolAccountSwitching(false);
}
}, [switchAccount]);
const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => {
const protocolAccounts = getWebcalProtocolAccounts();
if (protocolAccounts.length > 1) {
setPendingWebcalAccountChoice(pending);
return;
}
if (protocolAccounts.length === 0 && !supportsCalendar) {
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
if (accountId) {
void openWebcalForAccount(pending, accountId);
return;
}
setPendingSubscription({
url: pending.subscriptionUrl,
name: pending.suggestedName,
});
setShowWebcalActionChoice(true);
}, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]);
const closeWebcalActionChoice = useCallback(() => {
setShowWebcalActionChoice(false);
setPendingSubscription(null);
}, []);
const handleImportWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowImportModal(true);
}, []);
const handleSubscribeWebcal = useCallback(() => {
setShowWebcalActionChoice(false);
setShowSubscriptionModal(true);
}, []);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !client) return;
const openPendingWebcal = () => {
const pending = consumePendingWebcal();
if (!pending) return;
handleWebcalProtocolRequest(pending);
};
openPendingWebcal();
return subscribeToPendingWebcal(openPendingWebcal);
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
// Single-account fetch path. The Pro shell aggregates calendars from
// every connected account via [[useProMultiAccountCalendars]] below, so
// skip this fetch there to avoid clobbering the merged list with the
// active client's calendars only.
useEffect(() => {
if (isEmbedded) return;
if (client && !hasFetched.current) { if (client && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchCalendars(client); fetchCalendars(client);
} }
}, [client, fetchCalendars]); }, [client, fetchCalendars, isEmbedded]);
// Auto-refresh iCal subscriptions // Auto-refresh iCal subscriptions
useEffect(() => { useEffect(() => {
@@ -241,10 +343,21 @@ export default function CalendarPage() {
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]); }, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
useEffect(() => { useEffect(() => {
if (isEmbedded) return;
if (client && calendars.length > 0 && dateRange) { if (client && calendars.length > 0 && dateRange) {
fetchEvents(client, dateRange.start, dateRange.end); fetchEvents(client, dateRange.start, dateRange.end);
} }
}, [client, calendars.length, dateRange, fetchEvents]); }, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
// Pro shell only: aggregate calendars and events from every connected
// account so the sidebar lists them all (and the views render their
// events together). The hook is a no-op outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
isEmbedded ? dateRange?.start ?? null : null,
isEmbedded ? dateRange?.end ?? null : null,
);
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
const navigatePrev = useCallback(() => { const navigatePrev = useCallback(() => {
let next: Date; let next: Date;
@@ -316,6 +429,8 @@ export default function CalendarPage() {
setMobileReturnToMonth(true); setMobileReturnToMonth(true);
setViewMode("day"); setViewMode("day");
} }
// Close the narrow-pane sidebar overlay after the user picks a date.
setNarrowSidebarOpen(false);
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => { const navigateBackToMonth = useCallback(() => {
@@ -466,12 +581,15 @@ export default function CalendarPage() {
}, [events, client]); }, [events, client]);
const refetchCurrentRange = useCallback(async () => { const refetchCurrentRange = useCallback(async () => {
if (!client) return; if (!client || !activeAccountId) return;
const { dateRange: currentRange } = useCalendarStore.getState(); const { dateRange: currentRange } = useCalendarStore.getState();
if (currentRange) { if (!currentRange) return;
await fetchEvents(client, currentRange.start, currentRange.end); if (multiAccountEnabled && accountClients.length > 0) {
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
return;
} }
}, [client, fetchEvents]); await fetchEvents(client, currentRange.start, currentRange.end);
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page. // and refresh calendar data via JMAP instead of reloading the page.
@@ -479,8 +597,11 @@ export default function CalendarPage() {
enabled: isAuthenticated && !!client, enabled: isAuthenticated && !!client,
onRefresh: async () => { onRefresh: async () => {
if (!client) return; if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([ await Promise.all([
fetchCalendars(client), calendarRefresh,
refetchCurrentRange(), refetchCurrentRange(),
refreshAllSubscriptions(client), refreshAllSubscriptions(client),
]); ]);
@@ -955,7 +1076,54 @@ export default function CalendarPage() {
}); });
}, [events, selectedCalendarIds, visibleEvents]); }, [events, selectedCalendarIds, visibleEvents]);
if (!isAuthenticated || !supportsCalendar) return null; const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? (
<ProtocolAccountPicker
kind="webcal"
operation={pendingWebcalAccountChoice}
accounts={getWebcalProtocolAccounts()}
activeAccountId={activeAccountId}
isSwitching={isProtocolAccountSwitching}
onSelect={(accountId) => void openWebcalForAccount(pendingWebcalAccountChoice, accountId)}
onCancel={() => setPendingWebcalAccountChoice(null)}
/>
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={tWebcalAction("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
</Button>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
</div>
</div>
</div>
) : null;
if (!isAuthenticated) return null;
if (!supportsCalendar) return renderWebcalAccountPicker();
const renderView = () => { const renderView = () => {
if (isLoading && calendars.length === 0) { if (isLoading && calendars.length === 0) {
@@ -1082,9 +1250,11 @@ export default function CalendarPage() {
}; };
return ( return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{/* Left Navigation Rail */} <AppTopBannerSlot />
{!isMobile && ( <div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -1103,15 +1273,31 @@ export default function CalendarPage() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)} )}
{/* Sidebar - full height */} {/* Narrow-pane backdrop: dim and close overlay sidebar */}
{!isMobile && !inlineApp && ( {isNarrow && narrowSidebarOpen && !inlineApp && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{!inlineApp && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3", "border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300" !isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${calSidebarWidth}px` }} style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
> >
<MiniCalendar <MiniCalendar
selectedDate={selectedDate} selectedDate={selectedDate}
@@ -1172,8 +1358,10 @@ export default function CalendarPage() {
onSubscribe={() => setShowSubscriptionModal(true)} onSubscribe={() => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)} onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client} client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
{!isNarrow && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
@@ -1183,6 +1371,7 @@ export default function CalendarPage() {
}} }}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/> />
)}
</> </>
)} )}
@@ -1204,6 +1393,7 @@ export default function CalendarPage() {
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks} enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
<div <div
@@ -1276,7 +1466,7 @@ export default function CalendarPage() {
)} )}
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
{isMobile && ( {isMobile && !isEmbedded && (
<div className="shrink-0"> <div className="shrink-0">
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
@@ -1378,14 +1568,23 @@ export default function CalendarPage() {
<ICalImportModal <ICalImportModal
calendars={calendars} calendars={calendars}
client={client} client={client}
onClose={() => setShowImportModal(false)} initialUrl={pendingSubscription?.url}
onClose={() => {
setShowImportModal(false);
setPendingSubscription(null);
}}
/> />
)} )}
{showSubscriptionModal && client && ( {showSubscriptionModal && client && (
<ICalSubscriptionModal <ICalSubscriptionModal
client={client} client={client}
onClose={() => setShowSubscriptionModal(false)} initialUrl={pendingSubscription?.url}
initialName={pendingSubscription?.name}
onClose={() => {
setShowSubscriptionModal(false);
setPendingSubscription(null);
}}
/> />
)} )}
@@ -1402,6 +1601,8 @@ export default function CalendarPage() {
})()} })()}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
{renderWebcalAccountPicker()}
{renderWebcalActionChoice()}
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"} actionType={pendingScopeAction?.type || "edit"}
@@ -1436,5 +1637,6 @@ export default function CalendarPage() {
); );
})()} })()}
</div> </div>
</div>
); );
} }
@@ -2,7 +2,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ArrowLeft, Users } from "lucide-react"; import { useSearchParams } from "next/navigation";
import { useRouter } from "@/i18n/navigation";
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -15,17 +17,21 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { RenameDialog } from "@/components/files/rename-dialog"; import { RenameDialog } from "@/components/files/rename-dialog";
import { exportContacts } from "@/components/contacts/contact-export"; import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn, generateUUID } from "@/lib/utils"; import { cn, generateUUID } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -42,6 +48,7 @@ type View =
export default function ContactsPage() { export default function ContactsPage() {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -88,11 +95,26 @@ export default function ContactsPage() {
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null); const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null); const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined); const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
const [returnToEmail, setReturnToEmail] = useState(false);
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null); const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
const router = useRouter();
const searchParams = useSearchParams();
// One-shot intent flag: only consume the URL params on the first render that
// has them. After applying, we strip the query so a later refresh or
// re-mount doesn't re-trigger the navigation.
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Panel resize state - sidebar (categories) // Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -129,12 +151,41 @@ export default function ContactsPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Pro shell only: aggregate contacts and address books from every
// connected account so the sidebar lists them all. The hook is a no-op
// outside the embedded shell.
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
useEffect(() => { useEffect(() => {
if (isEmbedded) return;
if (client && supportsSync && !hasFetched.current) { if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true; hasFetched.current = true;
fetchContacts(client); fetchContacts(client);
} }
}, [client, supportsSync, fetchContacts]); }, [client, supportsSync, fetchContacts, isEmbedded]);
// Consume one-shot URL params (set by the mobile recipient popover when no
// sidebar is available) and strip them so a refresh doesn't replay the
// intent. `from=email` flips the mobile back button to `router.back()`.
useEffect(() => {
if (intentAppliedRef.current) return;
const contactId = searchParams.get('contactId');
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
if (!contactId && !addEmail && !from) return;
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView('detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
setSelectedContact(null);
setView('create');
}
router.replace('/contacts');
}, [searchParams, router, setSelectedContact]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page. // and refresh contacts via JMAP instead of reloading the page.
@@ -142,6 +193,17 @@ export default function ContactsPage() {
enabled: isAuthenticated && !!client && supportsSync, enabled: isAuthenticated && !!client && supportsSync,
onRefresh: async () => { onRefresh: async () => {
if (!client) return; if (!client) return;
if (multiAccountEnabled && accountClients.length > 0) {
const activeId = useAuthStore.getState().activeAccountId;
if (activeId) {
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
await Promise.all([
fetchAllAccountsAddressBooks(accountClients, activeId),
fetchAllAccountsContacts(accountClients, activeId),
]);
return;
}
}
await fetchContacts(client); await fetchContacts(client);
}, },
}); });
@@ -193,6 +255,7 @@ export default function ContactsPage() {
} else { } else {
setSelectedGroupId(null); setSelectedGroupId(null);
} }
setNarrowSidebarOpen(false);
}, [clearSelection]); }, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -335,8 +398,14 @@ export default function ContactsPage() {
toast.success(t("toast.created")); toast.success(t("toast.created"));
} }
setDefaultBookIdForCreate(undefined); setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
setReturnToEmail(false);
router.back();
return;
}
setView("list"); setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]); }, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => { const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return; if (!selectedContact) return;
@@ -353,6 +422,14 @@ export default function ContactsPage() {
const handleCancel = () => { const handleCancel = () => {
setDefaultBookIdForCreate(undefined); setDefaultBookIdForCreate(undefined);
// Came from email → cancel returns to the email instead of the contact list.
if (returnToEmail && view === "create") {
setCreatePrefill(undefined);
setReturnToEmail(false);
router.back();
return;
}
if (view === "create") setCreatePrefill(undefined);
if (view === "group-create" || view === "group-edit") { if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list"); setView(selectedGroup ? "group-detail" : "list");
} else if (view === "bulk-add-to-group") { } else if (view === "bulk-add-to-group") {
@@ -524,7 +601,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -640,18 +717,38 @@ export default function ContactsPage() {
} }
}; };
if (!contactsEnabled) {
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
</div>
</div>
);
}
const showListPanel = !isMobile || view === "list"; const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list"; const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => { const mobileBackToList = () => {
if (returnToEmail) {
setReturnToEmail(false);
setCreatePrefill(undefined);
router.back();
return;
}
setView("list"); setView("list");
clearSelection(); clearSelection();
}; };
return ( return (
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{/* Navigation Rail - desktop only */} <AppTopBannerSlot />
{!isMobile && ( <div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -670,18 +767,33 @@ export default function ContactsPage() {
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{showListPanel && ( {showListPanel && (
<> <>
{/* Panel 1: Categories sidebar */} {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{!isMobile && ( {(!isMobile || isNarrow) && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border flex flex-col flex-shrink-0", "border-r border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300" !isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)} )}
style={{ width: `${sidebarWidth}px` }} style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
> >
<ContactsSidebar <ContactsSidebar
groups={groups} groups={groups}
@@ -718,8 +830,10 @@ export default function ContactsPage() {
} }
} : undefined} } : undefined}
onRenameKeyword={(kw) => setRenamingKeyword(kw)} onRenameKeyword={(kw) => setRenamingKeyword(kw)}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/> />
</div> </div>
{!isNarrow && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
@@ -729,6 +843,7 @@ export default function ContactsPage() {
}} }}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/> />
)}
</> </>
)} )}
@@ -761,6 +876,7 @@ export default function ContactsPage() {
onEditContact={handleEditContact} onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact} onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup} onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
</div> </div>
@@ -790,7 +906,7 @@ export default function ContactsPage() {
className="touch-manipulation" className="touch-manipulation"
> >
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_contacts")} {returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button> </Button>
</div> </div>
)} )}
@@ -801,7 +917,7 @@ export default function ContactsPage() {
)} )}
</div> </div>
{isMobile && ( {isMobile && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -883,5 +999,6 @@ export default function ContactsPage() {
); );
})()} })()}
</div> </div>
</div>
); );
} }
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -16,6 +17,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view"; import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import { usePolicyStore } from "@/stores/policy-store"; import { usePolicyStore } from "@/stores/policy-store";
@@ -24,6 +26,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
export default function FilesPage() { export default function FilesPage() {
@@ -31,6 +34,9 @@ export default function FilesPage() {
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
const accounts = useAccountStore((s) => s.accounts);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
@@ -83,6 +89,7 @@ export default function FilesPage() {
} = useFileStore(); } = useFileStore();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isEmbedded = useIsEmbedded();
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout); const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
const hasFetched = useRef(false); const hasFetched = useRef(false);
@@ -127,13 +134,18 @@ export default function FilesPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [initialCheckDone, isAuthenticated, authLoading]);
// Initialize JMAP files client // Initialize JMAP files client. In the Pro shell, all connected accounts
// are surfaced as top-level folders at the root, so we *don't* auto-attach
// to the active account - the user picks one explicitly.
useEffect(() => { useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) { if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true; hasFetched.current = true;
initClient(client); if (isEmbedded) {
useFileStore.getState().clearClient();
} else {
initClient(client, activeAccountId);
} }
}, [isAuthenticated, client, initClient]); }, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page. // and refresh files via JMAP instead of reloading the page.
@@ -157,6 +169,17 @@ export default function FilesPage() {
}, [storeClient, supportsFiles, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => { const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
// account's filesystem root" via a sentinel, distinguishing it from a
// Home click (which detaches the account and returns to the picker).
if (resourceId === '__account_root__') {
void navigate(null);
return;
}
if (isEmbedded && path === '/' && resourceId === undefined) {
useFileStore.getState().clearClient();
return;
}
if (resourceId !== undefined) { if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder) // Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || ''); navigate(resourceId, path.split('/').pop() || '');
@@ -164,7 +187,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files) // Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path); navigateByPath(path);
} }
}, [navigate, navigateByPath]); }, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => { const handleCreateFolder = useCallback(async (name: string) => {
try { try {
@@ -371,11 +394,45 @@ export default function FilesPage() {
setShowDetails(v => !v); setShowDetails(v => !v);
}, []); }, []);
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker - no
// account's files are shown until the user enters one.
const accountFolders = isEmbedded
? accounts
.filter((a) => a.isConnected)
.map((a) => ({
accountId: a.id,
label: a.label || a.email,
email: a.email,
avatarColor: a.avatarColor,
}))
: [];
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
const currentAccountLabel = isEmbedded && currentFilesAccountId
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|| null)
: null;
const handleSelectAccount = useCallback((accountId: string) => {
const nextClient = getClientForAccount(accountId);
if (!nextClient) return;
const store = useFileStore.getState();
store.initClient(nextClient, accountId);
// Reset supportsFiles so the existing checkSupport effect re-runs for
// the freshly-attached client and triggers the initial navigate(null).
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
return ( return (
<div className="flex h-dvh bg-background overflow-hidden"> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
{!isMobile && ( <AppTopBannerSlot />
<div className="flex flex-1 min-h-0 overflow-hidden">
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail <NavigationRail
collapsed collapsed
@@ -396,7 +453,7 @@ export default function FilesPage() {
)} )}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}> <div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && ( {folderLayout !== "sidebar" && !isEmbedded && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}> <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@@ -474,6 +531,10 @@ export default function FilesPage() {
showDetails={showDetails} showDetails={showDetails}
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
/> />
</div> </div>
)} )}
@@ -481,7 +542,7 @@ export default function FilesPage() {
</div> </div>
</div> </div>
{isMobile && ( {isMobile && !isEmbedded && (
<NavigationRail <NavigationRail
orientation="horizontal" orientation="horizontal"
onManageApps={handleManageApps} onManageApps={handleManageApps}
@@ -515,5 +576,6 @@ export default function FilesPage() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} /> <SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </div>
</div>
); );
} }
@@ -5,6 +5,10 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider"; import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider"; import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
import { TourProvider } from "@/components/tour/tour-provider"; import { TourProvider } from "@/components/tour/tour-provider";
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
import { locales } from "@/i18n/routing"; import { locales } from "@/i18n/routing";
export default async function LocaleLayout({ export default async function LocaleLayout({
@@ -32,7 +36,12 @@ export default async function LocaleLayout({
<RateLimitToastProvider> <RateLimitToastProvider>
<EmbeddedBridgeProvider> <EmbeddedBridgeProvider>
<TourProvider> <TourProvider>
<ProtocolLaunchHandlerProvider>
<ProInterfaceRedirect />
{children} {children}
<PluginDialogHost />
<PluginConsentDialog />
</ProtocolLaunchHandlerProvider>
</TourProvider> </TourProvider>
</EmbeddedBridgeProvider> </EmbeddedBridgeProvider>
</RateLimitToastProvider> </RateLimitToastProvider>
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
@@ -15,7 +16,15 @@ import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { useUpdateStore, selectBanner } from "@/stores/update-store";
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined {
if (!email || !email.includes("@")) return undefined;
const domain = email.split("@")[1]?.trim().toLowerCase();
if (!domain) return undefined;
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
}
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
@@ -28,7 +37,12 @@ const THEME_OPTIONS = [
function VersionBadge() { function VersionBadge() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`; const banner = useUpdateStore(useShallow(selectBanner));
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startPolling(); }, [startPolling]);
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(versionInfo).then(() => { navigator.clipboard.writeText(versionInfo).then(() => {
@@ -37,16 +51,49 @@ function VersionBadge() {
}); });
}; };
const isRed = banner?.variant === "red";
const triggerText = !banner
? `v${APP_VERSION}`
: banner.severity === "security"
? "Security update available"
: banner.severity === "deprecated"
? "Version no longer supported"
: "New version available";
const triggerColor = !banner
? "text-muted-foreground/40"
: isRed
? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400"
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
const triggerClass = cn(
"peer text-center text-xs transition-colors",
triggerColor,
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
);
const trigger = banner?.url ? (
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
{triggerText}
</a>
) : (
<p className={triggerClass}>{triggerText}</p>
);
return ( return (
<div className="relative inline-flex justify-center"> <div className="relative inline-flex justify-center">
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default"> {trigger}
v{APP_VERSION}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10"> <div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="space-y-0.5"> <div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p> <p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p> <p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
{banner?.latest && (
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
)}
{banner?.advisory && (
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
)}
</div> </div>
<button <button
onClick={handleCopy} onClick={handleCopy}
@@ -61,15 +108,32 @@ 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://";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("login"); const t = useTranslations("login");
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account"; const isAddAccountMode = searchParams.get("mode") === "add-account";
// When the mobile app launches the webmail in a browser tab it tacks on
// 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") ?? "";
const rawMobileState = searchParams.get("mobile_state") ?? "";
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
? rawMobileRedirectUri
: "";
const mobileState = mobileRedirectUri ? rawMobileState : "";
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig(); const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -77,6 +141,18 @@ export default function LoginPage() {
password: "", password: "",
}); });
const [jmapEndpoint, setJmapEndpoint] = useState(""); const [jmapEndpoint, setJmapEndpoint] = useState("");
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [domainAutoLocked, setDomainAutoLocked] = useState(false);
const hasServerList = jmapServers.length > 0;
const selectedServer = hasServerList
? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0]
: undefined;
// Effective values: per-server overrides win, then global config.
const serverUrl = selectedServer?.url || configuredServerUrl;
const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId;
const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl;
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false); const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
@@ -100,6 +176,9 @@ export default function LoginPage() {
const totpInputRef = useRef<HTMLInputElement>(null); const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null); const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null); const themeMenuRef = useRef<HTMLDivElement>(null);
// Captured by handleSubmit when in mobile handoff mode; consumed by the
// isAuthenticated effect to build the deep-link fragment.
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
useEffect(() => { useEffect(() => {
initializeTheme(); initializeTheme();
@@ -117,6 +196,27 @@ export default function LoginPage() {
} }
}, [serverUrl, jmapEndpoint]); }, [serverUrl, jmapEndpoint]);
// Initialize selected server when the server list arrives. Picks the first
// entry; the auto-pick effect below may override based on the email domain.
useEffect(() => {
if (!hasServerList) return;
if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return;
setSelectedServerId(jmapServers[0].id);
}, [hasServerList, jmapServers, selectedServerId]);
// Auto-pick by email domain. Locks the dropdown to the matched server until
// the user clears the email or types a domain we don't recognize.
useEffect(() => {
if (!jmapServerAutoPickByDomain || !hasServerList) return;
const match = findServerByDomain(jmapServers, formData.username);
if (match) {
if (selectedServerId !== match.id) setSelectedServerId(match.id);
setDomainAutoLocked(true);
} else {
setDomainAutoLocked(false);
}
}, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]);
useEffect(() => { useEffect(() => {
try { try {
if (sessionStorage.getItem('session_expired') === 'true') { if (sessionStorage.getItem('session_expired') === 'true') {
@@ -158,6 +258,19 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (isAuthenticated && !isAddAccountMode) { 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;
}
let redirectTo = '/'; let redirectTo = '/';
try { try {
const saved = sessionStorage.getItem('redirect_after_login'); const saved = sessionStorage.getItem('redirect_after_login');
@@ -168,7 +281,7 @@ export default function LoginPage() {
} catch { /* ignore */ } } catch { /* ignore */ }
router.push(redirectTo); router.push(redirectTo);
} }
}, [isAuthenticated, router, isAddAccountMode]); }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => { useEffect(() => {
clearError(); clearError();
@@ -214,7 +327,9 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!oauthEnabled || !serverUrl) return; if (!oauthEnabled || !serverUrl) return;
discoverOAuth(oauthIssuerUrl || serverUrl) setOauthDiscoveryDone(false);
setOauthMetadata(null);
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
.then((metadata) => { .then((metadata) => {
setOauthMetadata(metadata); setOauthMetadata(metadata);
setOauthDiscoveryDone(true); setOauthDiscoveryDone(true);
@@ -223,7 +338,7 @@ export default function LoginPage() {
setOauthMetadata(null); setOauthMetadata(null);
setOauthDiscoveryDone(true); setOauthDiscoveryDone(true);
}); });
}, [oauthEnabled, serverUrl, oauthIssuerUrl]); }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
const ssoError = searchParams.get("sso_error"); const ssoError = searchParams.get("sso_error");
@@ -234,11 +349,28 @@ export default function LoginPage() {
try { try {
const prefix = getPathPrefix(params.locale as string); const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
// In mobile-handoff mode the callback page needs to know it should
// redirect into the app rather than into /mail. Stash the params in
// sessionStorage so the same-tab callback can read them - the SSO
// pending cookie carries the authoritative copy server-side too.
if (isMobileHandoff) {
try {
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
sessionStorage.setItem("mobile_state", mobileState);
} catch { /* sessionStorage unavailable */ }
}
const res = await apiFetch('/api/auth/sso/start', { const res = await apiFetch('/api/auth/sso/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }), body: JSON.stringify({
redirect_uri: redirectUri,
locale: params.locale,
server_id: selectedServer?.id,
...(isMobileHandoff
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
: {}),
}),
}); });
if (!res.ok) { if (!res.ok) {
@@ -264,7 +396,7 @@ export default function LoginPage() {
} catch { } catch {
setOauthLoading(false); setOauthLoading(false);
} }
}, [params.locale]); }, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
useEffect(() => { useEffect(() => {
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
@@ -405,26 +537,56 @@ export default function LoginPage() {
}; };
const handleOAuthLogin = async () => { const handleOAuthLogin = async () => {
if (!oauthMetadata || !oauthClientId) return; if (!oauthMetadata || !effectiveOauthClientId) return;
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
// tokens would land in sessionStorage on the webmail origin and the
// mobile app couldn't read them. Route through the server-side SSO
// path instead, which has the mobile-aware /api/auth/sso/complete
// branch.
if (isMobileHandoff) {
await startServerSideSso();
return;
}
setOauthLoading(true); setOauthLoading(true);
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier); const challenge = await generateCodeChallenge(verifier);
const state = generateState(); const state = generateState();
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; const prefix = getPathPrefix(params.locale as string);
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
// Resolve the JMAP URL to send to the callback. Server-list entries win
// over the custom-endpoint input, which wins over the global server URL.
const oauthServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl);
sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!); sessionStorage.setItem("oauth_server_url", oauthServerUrl!);
if (selectedServer?.id) {
sessionStorage.setItem("oauth_server_id", selectedServer.id);
} else {
sessionStorage.removeItem("oauth_server_id");
}
if (isAddAccountMode) { if (isAddAccountMode) {
sessionStorage.setItem("oauth_add_account_mode", "true"); sessionStorage.setItem("oauth_add_account_mode", "true");
} }
// Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts)
// writes the refresh token to the correct per-account jmap_rt_<slot> cookie.
// loginWithOAuth reads this key but it was previously never written, so every
// OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh
// tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for
// first sign-in) and the lowest unused slot otherwise (correct for "+ Add
// Account").
const nextSlot = useAccountStore.getState().getNextCookieSlot();
sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString());
const authUrl = new URL(oauthMetadata.authorization_endpoint); const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oauthClientId); authUrl.searchParams.set("client_id", effectiveOauthClientId);
authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", OAUTH_SCOPES); authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
authUrl.searchParams.set("state", state); authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256"); authUrl.searchParams.set("code_challenge_method", "S256");
@@ -435,7 +597,20 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl; // Server-list entries always win - `allowCustomJmapEndpoint` is only honored
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// Capture before login() so the isAuthenticated effect can build the
// deep-link fragment with values the user actually typed (formData may
// be cleared by the auth store on success).
if (isMobileHandoff) {
mobileHandoffPayloadRef.current = {
server_url: effectiveServerUrl,
username: formData.username,
password: formData.password,
};
}
const success = await login( const success = await login(
effectiveServerUrl, effectiveServerUrl,
formData.username, formData.username,
@@ -446,7 +621,15 @@ export default function LoginPage() {
if (success) { if (success) {
saveUsername(formData.username); 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.
return;
}
router.push('/'); router.push('/');
} else if (isMobileHandoff) {
// Stale payload should never feed into a later retry's redirect.
mobileHandoffPayloadRef.current = null;
} }
}; };
@@ -556,14 +739,18 @@ export default function LoginPage() {
<div className="px-8 pb-10 pt-4"> <div className="px-8 pb-10 pt-4">
{error && ( {error && (
<div className={cn( <div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3", "mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
shakeError && "animate-shake" shakeError && "animate-shake"
)}> )}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" /> <div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed"> <AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center">
<p className="text-sm text-destructive leading-relaxed">
{t(`error.${error}`) || t("error.generic")} {t(`error.${error}`) || t("error.generic")}
</p> </p>
</div> </div>
</div>
)} )}
<Button <Button
@@ -703,38 +890,46 @@ export default function LoginPage() {
{/* Session Expired Banner */} {/* Session Expired Banner */}
{sessionExpired && ( {sessionExpired && (
<div <div
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3" className="mb-5 p-3 rounded-xl border border-info/20 bg-info/5 flex items-start gap-3"
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" /> <div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed"> <Info className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
<p className="text-sm text-info flex-1 leading-relaxed">
{t("session_expired")} {t("session_expired")}
</p> </p>
<button <button
type="button" type="button"
onClick={() => setSessionExpired(false)} onClick={() => setSessionExpired(false)}
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0" className="p-1 rounded-md text-info hover:bg-info/10 transition-colors flex-shrink-0"
aria-label={t("dismiss")} aria-label={t("dismiss")}
> >
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
</div>
)} )}
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className={cn( <div className={cn(
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3", "mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
shakeError && "animate-shake" shakeError && "animate-shake"
)}> )}>
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" /> <div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed"> <AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center">
<p className="text-sm text-destructive leading-relaxed">
{error === 'invalid_credentials' && showTotpField && totpCode {error === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid') ? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")} : t(`error.${error}`) || t("error.generic")}
</p> </p>
</div> </div>
</div>
)} )}
{/* Dev Mode: One-click login */} {/* Dev Mode: One-click login */}
@@ -785,12 +980,16 @@ export default function LoginPage() {
)} )}
</Button> </Button>
) : oauthDiscoveryDone ? ( ) : oauthDiscoveryDone ? (
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2"> <div className="p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" /> <div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
<p className="text-sm text-warning"> <AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center">
<p className="text-sm text-warning leading-relaxed">
{t("error.oauth_discovery_failed")} {t("error.oauth_discovery_failed")}
</p> </p>
</div> </div>
</div>
) : ( ) : (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary" /> <Loader2 className="w-6 h-6 animate-spin text-primary" />
@@ -801,8 +1000,32 @@ export default function LoginPage() {
/* Login Form */ /* Login Form */
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<fieldset disabled={isLoading} className="space-y-4"> <fieldset disabled={isLoading} className="space-y-4">
{/* JMAP Endpoint field (when custom endpoints are allowed) */} {/* Server picker (when admin has configured a server list) */}
{allowCustomJmapEndpoint && ( {hasServerList && jmapServers.length > 1 && (
<div className="space-y-1.5">
<label htmlFor="jmap-server-select" className="block text-sm font-medium text-foreground">
{t("jmap_server_label")}
</label>
<select
id="jmap-server-select"
value={selectedServer?.id ?? ""}
onChange={(e) => setSelectedServerId(e.target.value)}
disabled={domainAutoLocked}
className="h-11 w-full px-3.5 bg-muted/40 border border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-sm text-foreground disabled:opacity-70 disabled:cursor-not-allowed"
>
{jmapServers.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
{domainAutoLocked && (
<p className="text-[11px] text-muted-foreground leading-snug">
{t("jmap_server_auto_picked")}
</p>
)}
</div>
)}
{/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */}
{!hasServerList && allowCustomJmapEndpoint && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground"> <label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
{t("jmap_endpoint_label")} {t("jmap_endpoint_label")}
@@ -1017,12 +1240,16 @@ export default function LoginPage() {
)} )}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2"> <div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" /> <div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
<p className="text-sm text-warning"> <AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 self-center">
<p className="text-sm text-warning leading-relaxed">
{t("error.oauth_discovery_failed")} {t("error.oauth_discovery_failed")}
</p> </p>
</div> </div>
</div>
)} )}
</form> </form>
)} )}
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
import FilesPage from "@/app/(main)/[locale]/files/page";
import SettingsPage from "@/app/(main)/[locale]/settings/page";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
mail: MailPage,
calendar: CalendarPage,
contacts: ContactsPage,
files: FilesPage,
settings: SettingsPage,
};
type DropTarget = 'left' | 'right' | null;
function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'compose' && tab.composeData) {
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
}
if (tab.kind === 'email' && tab.emailData) {
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
}
const Component = APP_TAB_COMPONENTS[tab.kind];
return Component ? <Component /> : null;
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
activeTabId: string | null;
loadedTabIds: string[];
onPaneFocus: (paneId: ProPaneId) => void;
isFocused: boolean;
}
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
// viewport - and inner pages collapse to their mobile/tablet layouts when
// the pane is narrow.
const [paneWidth, setPaneWidth] = useState<number | null>(null);
useEffect(() => {
const el = paneRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const initialRect = el.getBoundingClientRect();
if (initialRect.width > 0) setPaneWidth(initialRect.width);
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const w = entry.contentRect.width;
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div
ref={paneRef}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
</div>
);
}
export default function ProHome() {
const t = useTranslations();
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
const [initialCheckDone, setInitialCheckDone] = useState(
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const {
showAppsModal,
inlineApp,
loadedApps,
handleManageApps,
handleInlineApp,
closeInlineApp,
closeAppsModal,
} = useSidebarApps();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const client = useAuthStore((s) => s.client);
const logout = useAuthStore((s) => s.logout);
const checkAuth = useAuthStore((s) => s.checkAuth);
const authLoading = useAuthStore((s) => s.isLoading);
const quota = useEmailStore((s) => s.quota);
const isPushConnected = useEmailStore((s) => s.isPushConnected);
const proInterface = useSettingsStore((s) => s.proInterface);
const tabs = useProTabStore((s) => s.tabs);
const activeMainTabId = useProTabStore((s) => s.activeTabId);
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
const splitOrientation = useProTabStore((s) => s.splitOrientation);
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
const [isTabDragging, setIsTabDragging] = useState(false);
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
/** Whether the split pane visually renders before (true) or after (false) main. */
const [splitLeading, setSplitLeading] = useState(false);
// Auth bootstrap (mirrors standard page)
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
useEffect(() => {
if (!initialCheckDone || typeof window === "undefined") return;
// Pro is desktop-only, and only used when the user has explicitly
// enabled it. If either precondition stops holding, hand the user back
// to the standard shell.
if (isMobile || isTablet || !proInterface) {
window.location.replace("/");
}
}, [initialCheckDone, isMobile, isTablet, proInterface]);
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
const focusedActiveTab = useMemo(() => {
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
return tabs.find((t) => t.id === id) ?? null;
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
openTab(itemId);
return true;
};
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
focusedActiveTab && (
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|| focusedActiveTab.kind === 'settings'
) ? focusedActiveTab.kind : null;
const isSplit = splitOrientation !== null && splitTabs.length > 0;
// ---- Body-level drop targets ----
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
const rect = e.currentTarget.getBoundingClientRect();
const xFrac = (e.clientX - rect.left) / rect.width;
return xFrac < 0.5 ? 'left' : 'right';
};
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
if (!target || !isSplit) return null;
const leftIsSplit = splitLeading;
if (target === 'left') return leftIsSplit ? 'split' : 'main';
return leftIsSplit ? 'main' : 'split';
};
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const next = computeDropTarget(e);
if (next !== splitDropTarget) setSplitDropTarget(next);
};
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) return;
setSplitDropTarget(null);
};
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
if (!isProTabDrag(e)) return;
const target = computeDropTarget(e);
setSplitDropTarget(null);
setIsTabDragging(false);
if (!target) return;
e.preventDefault();
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
if (!draggedId) return;
if (isSplit) {
// Move tab to whichever pane occupies the dropped side.
const destPane = targetPaneFromDrop(target);
if (destPane) moveTabToPane(draggedId, destPane);
return;
}
// Create a new side-by-side split. `splitLeading` controls which side
// visually hosts the split pane.
moveTabToPane(draggedId, 'split', 'vertical');
setSplitLeading(target === 'left');
};
// Loading state (matches standard page exactly)
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
if (!isDesktop) return null;
// Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at
// index 0 - repurposing the *split* pane's instance into the main pane,
// which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed).
const mainPane = (
<Pane
key="pane-main"
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'main'}
/>
);
const splitPane = isSplit ? (
<Pane
key="pane-split"
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
loadedTabIds={loadedTabIds}
onPaneFocus={setFocusedPane}
isFocused={focusedPaneId === 'split'}
/>
) : null;
const splitDivider = isSplit ? (
<div
key="pane-divider"
aria-hidden="true"
className="flex-shrink-0 w-px bg-transparent"
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
/>
) : null;
// Drop-zone overlay: a single half-body preview of where the dragged tab
// would land. The whole body is always a drop target (the entire surface
// maps to one of the four sides), so we only render the active side.
const dropZone = isTabDragging && splitDropTarget ? (
<DropZone side={splitDropTarget} />
) : null;
return (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
<div className="flex flex-1 overflow-hidden">
{/* Leftmost Navigation Rail - identical to the standard layout */}
<div
className="w-14 bg-secondary flex flex-col flex-shrink-0"
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onShowShortcuts={() => setShowShortcutsModal(true)}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
onNavigate={handleRailNavigate}
activeItemId={railActiveItemId}
/>
</div>
{inlineApp && (
<InlineAppView
apps={loadedApps}
activeAppId={inlineApp.id}
onClose={closeInlineApp}
className="flex-1"
/>
)}
{!inlineApp && (
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
{/* Single, unified tab bar above both panes. */}
<ProTabBar
tabs={tabs}
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onDragStateChange={setIsTabDragging}
/>
{/* Panes container - accepts body drops for split/move. */}
<div
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
>
{isSplit
? (splitLeading
? <>{splitPane}{splitDivider}{mainPane}</>
: <>{mainPane}{splitDivider}{splitPane}</>)
: mainPane}
{dropZone}
</div>
</div>
)}
</div>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
{showAppsModal && (
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
)}
</div>
</EmbeddedContext.Provider>
);
}
function DropZone({ side }: { side: 'left' | 'right' }) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
side === 'left' ? "left-0" : "right-0",
)}
/>
);
}
+981
View File
@@ -0,0 +1,981 @@
"use client";
import { useState, useEffect, useRef, useMemo } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useMessages } from 'next-intl';
import {
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
Search,
User,
Shield,
UserPen,
PalmtreeIcon,
Calendar,
Filter,
FileText,
FolderOpen,
Tags,
HardDrive,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
LayoutGrid,
Link as LinkIcon,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
X,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
import { ComposingSettings } from '@/components/settings/composing-settings';
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AboutDataSettings } from '@/components/settings/about-data-settings';
import { DebugSettings } from '@/components/settings/debug-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
type Tab =
| 'account'
| 'language'
| 'notifications'
| 'appearance'
| 'layout'
| 'reading'
| 'composing'
| 'identities'
| 'vacation'
| 'filters'
| 'templates'
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
| 'files'
| 'protocol_handlers'
| 'sidebar_apps'
| 'about_data'
| 'themes'
| 'plugins'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
}
const tabIcons: Record<Tab, LucideIcon> = {
account: User,
language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen,
vacation: PalmtreeIcon,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
protocol_handlers: LinkIcon,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
plugins: Puzzle,
debug: Bug,
};
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Translation paths per tab. Tabs that share a namespace (email_behavior,
// appearance) explicitly list the subkeys they actually render so sub-results
// are attributed to the correct tab. Tabs with their own namespace just point
// at the namespace root.
const tabSearchPaths: Record<Tab, string[]> = {
account: [
'settings.account.name_label',
'settings.account.username_label',
'settings.account.account_type_label',
'settings.account.auth_method_label',
'settings.account.email',
'settings.account.server',
'settings.account.storage',
'settings.account.accounts',
],
language: ['settings.appearance.language'],
notifications: ['settings.notifications'],
appearance: [
'settings.appearance.theme',
'settings.appearance.font_size',
'settings.appearance.list_density',
'settings.appearance.animations',
],
layout: [
'settings.appearance.toolbar_position',
'settings.appearance.toolbar_labels',
'settings.appearance.hide_account_switcher',
'settings.appearance.show_rail_account_list',
'settings.appearance.unified_mailbox',
'settings.appearance.colorful_sidebar_icons',
'settings.email_behavior.mail_layout',
],
reading: [
'settings.email_behavior.mark_read',
'settings.email_behavior.archive_mode',
'settings.email_behavior.delete_action',
'settings.email_behavior.attachment_click_action',
'settings.email_behavior.attachment_image_previews',
'settings.email_behavior.attachment_position',
'settings.email_behavior.disable_threading',
'settings.email_behavior.emails_per_page',
'settings.email_behavior.hide_inline_image_attachments',
'settings.email_behavior.hover_actions',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.show_preview',
'settings.email_behavior.plain_text_mode',
],
composing: [
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.signature_position',
'settings.email_behavior.sub_address_delimiter',
],
identities: ['settings.identities'],
vacation: ['settings.vacation'],
filters: ['settings.filters'],
templates: ['settings.templates'],
folders: ['settings.folders'],
keywords: ['settings.keywords'],
security: ['settings.security'],
encryption: ['smime'],
content_senders: [
'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content',
'settings.email_behavior.trusted_senders',
],
calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'],
protocol_handlers: ['protocol_handlers'],
sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'],
about_data: ['settings.advanced'],
themes: [],
plugins: [],
debug: ['settings.advanced'],
};
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
const tabKeywords: Record<Tab, string> = {
account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account',
language: 'locale region timezone date time format',
notifications: 'sound alert push badge',
appearance: 'theme dark light font size accent color animation density',
layout: 'toolbar sidebar account switcher unified mailbox icons rail',
reading: 'mark read preview thread conversation archive delete attachment open',
composing: 'editor signature plain text reply forward draft compose',
identities: 'from address signature email',
vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules block junk forward',
templates: 'snippet quick reply',
folders: 'mailbox subscribe',
keywords: 'tags labels colors',
security: 'password 2fa two-factor passkey app password mfa',
encryption: 's/mime smime certificate pgp gpg',
content_senders: 'block sender remote images privacy tracking',
calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact',
files: 'attachments cloud drive storage upload',
protocol_handlers: 'mailto webcal links default app protocol handler',
sidebar_apps: 'apps webview iframe',
about_data: 'export import storage quota privacy backup',
themes: 'custom theme css skin appearance',
plugins: 'extensions addons',
debug: 'logs developer console diagnostic',
};
function flattenStrings(node: unknown, sink: string[]): void {
if (typeof node === 'string') {
sink.push(node);
return;
}
if (Array.isArray(node)) {
for (const item of node) flattenStrings(item, sink);
return;
}
if (node && typeof node === 'object') {
for (const value of Object.values(node)) flattenStrings(value, sink);
}
}
interface SubResult {
label: string;
description?: string;
// For plugin setting fields: the id of the plugin whose card needs to be
// expanded before the field becomes visible in the DOM.
pluginId?: string;
}
// Walk a translation subtree and emit sub-results for renderable settings.
// Picks up:
// - bare string leaves (when a tab path points directly at a flat label)
// - objects with a `label` or `title` field (the standard pattern)
// - flat `*_label` string keys at any object level (e.g. `name_label`)
function collectSubResults(node: unknown, sink: SubResult[]): void {
if (typeof node === 'string') {
sink.push({ label: node });
return;
}
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
const obj = node as Record<string, unknown>;
const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined);
if (label) {
sink.push({
label,
description: typeof obj.description === 'string' ? obj.description : undefined,
});
}
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && key !== 'label' && key !== 'title' && key.endsWith('_label')) {
sink.push({ label: value });
}
}
for (const value of Object.values(obj)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
collectSubResults(value, sink);
}
}
}
function getByPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.split('.')) {
if (cur && typeof cur === 'object' && key in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return cur;
}
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
email: 'reading',
advanced: 'about_data',
};
function readPersistedTab(): Tab {
try {
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
const migrated = LEGACY_TAB_MAP[saved];
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
} catch {
return 'appearance';
}
}
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const isEmbedded = useIsEmbedded();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
const isDesktop = useIsDesktop();
const messages = useMessages() as Record<string, unknown>;
const installedPlugins = usePluginStore((s) => s.plugins);
const installedThemes = useThemeStore((s) => s.installedThemes);
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
const proInterface = useSettingsStore((s) => s.proInterface);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
// that have a `label`/`title` field, plus dynamic content (installed
// plugins/themes/sidebar apps).
const { tabSearchHaystacks, tabSubResults } = useMemo(() => {
const haystacks: Partial<Record<Tab, string>> = {};
const subs: Partial<Record<Tab, SubResult[]>> = {};
const tabIds = Object.keys(tabSearchPaths) as Tab[];
for (const tabId of tabIds) {
const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? ''];
const list: SubResult[] = [];
for (const path of tabSearchPaths[tabId]) {
const node = getByPath(messages, path);
flattenStrings(node, strings);
collectSubResults(node, list);
}
// Dedupe sub-results by label
const seen = new Set<string>();
subs[tabId] = list.filter((r) => {
if (seen.has(r.label)) return false;
seen.add(r.label);
return true;
});
haystacks[tabId] = strings.join(' ').toLowerCase();
}
if (installedPlugins.length) {
const haystackText = installedPlugins.map((p) => {
const fieldText = p.settingsSchema
? Object.values(p.settingsSchema)
.map((s) => `${s.label} ${s.description ?? ''}`)
.join(' ')
: '';
return `${p.name} ${p.description} ${p.author} ${fieldText}`;
}).join(' ');
haystacks.plugins = `${haystacks.plugins ?? ''} ${haystackText}`.toLowerCase();
const pluginSubs: SubResult[] = installedPlugins.flatMap((p) => {
const items: SubResult[] = [{ label: p.name, description: p.description }];
if (p.settingsSchema) {
for (const schema of Object.values(p.settingsSchema)) {
items.push({
label: schema.label,
description: schema.description,
pluginId: p.id,
});
}
}
return items;
});
subs.plugins = [...(subs.plugins ?? []), ...pluginSubs];
}
if (installedThemes.length) {
const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' ');
haystacks.themes = `${haystacks.themes ?? ''} ${text}`.toLowerCase();
subs.themes = [
...(subs.themes ?? []),
...installedThemes.map((th) => ({ label: th.name, description: th.description })),
];
}
if (sidebarAppsList.length) {
const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' ');
haystacks.sidebar_apps = `${haystacks.sidebar_apps ?? ''} ${text}`.toLowerCase();
subs.sidebar_apps = [
...(subs.sidebar_apps ?? []),
...sidebarAppsList.map((a) => ({ label: a.name, description: a.url })),
];
}
return { tabSearchHaystacks: haystacks, tabSubResults: subs };
}, [messages, installedPlugins, installedThemes, sidebarAppsList]);
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Listen for tab change events from child components (with legacy migration)
useEffect(() => {
const handler = (e: Event) => {
const raw = (e as CustomEvent).detail as string;
if (!raw) return;
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Sync the mobile submenu view with browser history so the system back
// button (or gesture) returns to the settings list before exiting /settings.
useEffect(() => {
if (isDesktop) return;
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
window.history.pushState({ __settingsSubmenu: true }, '');
const handlePop = () => {
setMobileShowContent(false);
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [isDesktop, mobileShowContent]);
// After clicking a search sub-result, scroll the matching setting into view
// and add a temporary highlight class. Some tabs fetch data and render
// their SettingItems only after a loading state, so retry until the element
// shows up (or we give up after ~2s).
useEffect(() => {
if (!pendingHighlight) return;
if (pendingHighlight.tab !== activeTab) return;
if (typeof window === 'undefined') return;
// For plugin-setting sub-results, ask the plugins tab to expand the
// matching card so the field becomes part of the DOM. Dispatched here
// (not in the click handler) because PluginsSettings only mounts after
// the tab switches, and its listener registers in its own useEffect -
// child effects run before parent effects, so by the time we get here
// the listener is guaranteed to be in place.
if (pendingHighlight.pluginId) {
window.dispatchEvent(
new CustomEvent('settings-plugin-expand', { detail: { pluginId: pendingHighlight.pluginId } })
);
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let cleanupTimer: ReturnType<typeof setTimeout> | undefined;
let highlightedEl: HTMLElement | null = null;
const escaped = pendingHighlight.label.replace(/"/g, '\\"');
const selector = `[data-search-label="${escaped}"]`;
const deadline = Date.now() + 2000;
const tryHighlight = () => {
if (cancelled) return;
const el = document.querySelector<HTMLElement>(selector);
if (!el) {
if (Date.now() < deadline) {
retryTimer = setTimeout(tryHighlight, 80);
}
return;
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Remove + reflow + add restarts the CSS animation if the class was
// already present (re-clicking the same sub-result).
el.classList.remove('settings-search-highlight');
void el.offsetWidth;
el.classList.add('settings-search-highlight');
highlightedEl = el;
cleanupTimer = setTimeout(() => {
el.classList.remove('settings-search-highlight');
highlightedEl = null;
}, 1800);
};
// First attempt next frame so the freshly-mounted tab content is in DOM.
const raf = window.requestAnimationFrame(tryHighlight);
// Do NOT reset pendingHighlight here - that would retrigger this effect
// and the cleanup below would strip the class right after we added it.
return () => {
cancelled = true;
window.cancelAnimationFrame(raf);
if (retryTimer) clearTimeout(retryTimer);
if (cleanupTimer) clearTimeout(cleanupTimer);
if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight');
};
}, [pendingHighlight, activeTab]);
if (!isAuthenticated) {
return null;
}
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [
// General
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
const trimmedQuery = searchQuery.trim().toLowerCase();
const matchesQuery = (tab: TabDef) => {
if (!trimmedQuery) return true;
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
};
const subResultsForTab = (tabId: Tab): SubResult[] => {
if (!trimmedQuery) return [];
const list = tabSubResults[tabId] ?? [];
return list
.filter((r) =>
r.label.toLowerCase().includes(trimmedQuery) ||
(r.description?.toLowerCase().includes(trimmedQuery) ?? false)
)
.slice(0, 6);
};
const filteredGroupedTabs = trimmedQuery
? groupedTabs
.map((g) => ({ ...g, items: g.items.filter(matchesQuery) }))
.filter((g) => g.items.length > 0)
: groupedTabs;
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
setMobileShowContent(true);
}
};
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
handleTabSelect(tabId);
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'templates' && <TemplateSettings />}
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'protocol_handlers' && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
if (mobileShowContent) {
return (
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => window.history.back()}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
<div className="flex-1 overflow-y-auto p-4">
{renderTabContent()}
</div>
{!isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
return (
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/')}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-4 pt-3 pb-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-9 pr-9 h-10"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:bg-muted"
aria-label={t('search_clear')}
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="py-2">
{filteredGroupedTabs.length === 0 && (
<div className="px-5 py-6 text-sm text-muted-foreground text-center">
{t('search_no_results')}
</div>
)}
{filteredGroupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
<div className="px-5 pt-3 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
const subs = subResultsForTab(tab.id);
return (
<div key={tab.id}>
<button
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{subs.map((sub) => (
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
>
<span className="truncate">{sub.label}</span>
</button>
))}
</div>
);
})}
</div>
))}
</div>
<div className="border-t border-border px-5 py-3">
<button
onClick={logout}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
>
<LogOut className="w-4 h-4" />
<span>{tSidebar('sign_out')}</span>
</button>
</div>
</div>
{!isEmbedded && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
// Desktop layout
return (
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex flex-1 min-h-0">
{!isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
)}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{!inlineApp && (
<>
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
{!proInterface && (
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-3 pt-1 pb-1">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-8 pr-8 h-9 text-sm"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-md text-muted-foreground hover:bg-muted"
aria-label={t('search_clear')}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="px-2 space-y-0.5">
{filteredGroupedTabs.length === 0 && (
<div className="px-3 py-6 text-sm text-muted-foreground text-center">
{t('search_no_results')}
</div>
)}
{filteredGroupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
const subs = subResultsForTab(tab.id);
return (
<div key={tab.id}>
<button
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
</button>
{subs.map((sub) => (
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
>
<span className="truncate block">{sub.label}</span>
</button>
))}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
}}
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
{renderTabContent()}
</div>
</div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
</div>
);
}
@@ -0,0 +1,272 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface Props {
value: JmapServerEntry[];
source?: string;
onChange: (next: JmapServerEntry[]) => void;
onRevert: () => void;
}
interface RowDraft {
id: string;
label: string;
url: string;
domains: string;
oauthClientId: string;
oauthIssuerUrl: string;
oauthClientSecret: string;
oauthExpanded: boolean;
}
function entryToDraft(e: JmapServerEntry): RowDraft {
return {
id: e.id,
label: e.label,
url: e.url,
domains: (e.domains ?? []).join(', '),
oauthClientId: e.oauth?.clientId ?? '',
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
oauthClientSecret: e.oauth?.clientSecret ?? '',
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
};
}
function draftToEntry(d: RowDraft): JmapServerEntry | null {
const id = d.id.trim();
const url = d.url.trim().replace(/\/+$/, '');
if (!id || !url) return null;
const domains = d.domains
.split(/[,\s]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const clientId = d.oauthClientId.trim();
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
const clientSecret = d.oauthClientSecret;
const oauth = clientId || issuerUrl || clientSecret
? {
...(clientId ? { clientId } : {}),
...(issuerUrl ? { issuerUrl } : {}),
...(clientSecret ? { clientSecret } : {}),
}
: undefined;
return {
id,
label: d.label.trim() || id,
url,
...(domains.length > 0 ? { domains } : {}),
...(oauth ? { oauth } : {}),
};
}
function emptyDraft(): RowDraft {
return {
id: '',
label: '',
url: '',
domains: '',
oauthClientId: '',
oauthIssuerUrl: '',
oauthClientSecret: '',
oauthExpanded: false,
};
}
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
useEffect(() => {
// Re-sync from props when the underlying config value changes (e.g. revert,
// initial load). Skip when drafts already represent the same array to avoid
// clobbering in-progress edits.
setDrafts((prev) => {
if (prev.length === value.length) {
const same = prev.every((d, i) => {
const e = value[i];
return d.id === e.id && d.url === e.url && d.label === e.label;
});
if (same) return prev;
}
return value.map(entryToDraft);
});
}, [value]);
function commit(next: RowDraft[]) {
setDrafts(next);
const entries: JmapServerEntry[] = [];
for (const d of next) {
const e = draftToEntry(d);
if (e) entries.push(e);
}
onChange(entries);
}
function update(idx: number, patch: Partial<RowDraft>) {
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
}
function remove(idx: number) {
commit(drafts.filter((_, i) => i !== idx));
}
function add() {
setDrafts((prev) => [...prev, emptyDraft()]);
// Don't commit yet - new row needs id+url before it counts.
}
const ids = new Set<string>();
const duplicateIdx = new Set<number>();
drafts.forEach((d, i) => {
const id = d.id.trim();
if (!id) return;
if (ids.has(id)) duplicateIdx.add(i);
ids.add(id);
});
return (
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Servers</span>
{source && source !== 'default' && (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{source === 'admin' && (
<button
onClick={onRevert}
className="text-muted-foreground hover:text-foreground"
title="Revert to default"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={add}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add server
</button>
</div>
</div>
{drafts.length === 0 && (
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
)}
{drafts.map((d, i) => {
const isDuplicate = duplicateIdx.has(i);
return (
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
<input
type="text"
value={d.id}
onChange={(e) => update(i, { id: e.target.value })}
placeholder="main"
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
/>
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
</div>
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
<input
type="text"
value={d.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Main server"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="sm:col-span-5">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
<div className="flex items-center gap-2">
<input
type="url"
value={d.url}
onChange={(e) => update(i, { url: e.target.value })}
placeholder="https://mail.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={() => remove(i)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title="Remove server"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Email domains (comma-separated, used for auto-pick)
</label>
<input
type="text"
value={d.domains}
onChange={(e) => update(i, { domains: e.target.value })}
placeholder="example.com, example.org"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
type="button"
>
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
Per-server OAuth (optional, overrides global)
</button>
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
<input
type="text"
value={d.oauthClientId}
onChange={(e) => update(i, { oauthClientId: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
<input
type="url"
value={d.oauthIssuerUrl}
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
placeholder="https://auth.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
<input
type="password"
value={d.oauthClientSecret}
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -5,11 +5,15 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; // 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'; source: 'admin' | 'env' | 'default';
hasValue?: boolean;
} }
export default function AdminAuthPage() { export function AuthTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({}); const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({}); const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -129,8 +133,8 @@ export default function AdminAuthPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1> <h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p> <p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div> </div>
@@ -152,9 +156,8 @@ export default function AdminAuthPage() {
</div> </div>
)} )}
{/* Auto-setup */}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4"> <div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" /> <Sparkles className="w-4 h-4 text-primary shrink-0" />
@@ -176,7 +179,6 @@ export default function AdminAuthPage() {
</div> </div>
</div> </div>
{/* Auto-setup dialog */}
{setupOpen && ( {setupOpen && (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
@@ -265,21 +267,20 @@ export default function AdminAuthPage() {
</div> </div>
)} )}
{/* OAuth */}
<Section title="OAuth / OpenID Connect"> <Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} /> <Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" /> <Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" /> <Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
</Section> </Section>
{/* SSO */}
<Section title="Single Sign-On"> <Section title="Single Sign-On">
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</Section> </Section>
{/* Session & Security */}
<Section title="Session & Security"> <Section title="Session & Security">
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} /> <Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." /> <Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
@@ -314,7 +315,7 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -322,11 +323,11 @@ function Text({ label, description, configKey, value, source, onChange, onRevert
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder} <input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
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" /> 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' && ( {source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button> <button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)} )}
</div> </div>
</div> </div>
@@ -338,7 +339,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -346,7 +347,7 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => onChange(configKey, !value)} <button onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
@@ -364,12 +365,12 @@ function Select({ label, configKey, value, source, options, onChange, onRevert }
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} <select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{o}</option>)} {options.map(o => <option key={o} value={o}>{o}</option>)}
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value?: unknown;
source: 'admin' | 'env' | 'default'; source: 'admin' | 'env' | 'default';
hasValue?: boolean;
} }
const IMAGE_FIELDS = [ const IMAGE_FIELDS = [
@@ -24,7 +25,7 @@ const TEXT_FIELDS = [
{ key: 'loginWebsiteUrl', label: 'Company Website URL' }, { key: 'loginWebsiteUrl', label: 'Company Website URL' },
]; ];
export default function AdminBrandingPage() { export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({}); const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({}); const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -92,13 +93,11 @@ export default function AdminBrandingPage() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` }); setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
// Remove any pending URL edit for this slot since upload sets it
setEdits(prev => { setEdits(prev => {
const next = { ...prev }; const next = { ...prev };
delete next[slot]; delete next[slot];
return next; return next;
}); });
// Update config to reflect the uploaded URL
setConfig(prev => ({ setConfig(prev => ({
...prev, ...prev,
[slot]: { value: data.url, source: 'admin' }, [slot]: { value: data.url, source: 'admin' },
@@ -162,8 +161,8 @@ export default function AdminBrandingPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Branding</h1> <h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p> <p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div> </div>
@@ -193,22 +192,22 @@ export default function AdminBrandingPage() {
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => ( {IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3"> <div key={field.key} className="px-4 py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label> <label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary"> <span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'} {isUploadedFile(field.key) ? 'uploaded' : 'admin'}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={currentValue(field.key)} value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)} onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file" placeholder="Enter URL or upload a file"
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" className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
<input <input
ref={el => { fileInputRefs.current[field.key] = el; }} ref={el => { fileInputRefs.current[field.key] = el; }}
@@ -245,7 +244,6 @@ export default function AdminBrandingPage() {
)} )}
</div> </div>
</div> </div>
{/* Preview */}
{currentValue(field.key) && ( {currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" /> <ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
@@ -270,20 +268,20 @@ export default function AdminBrandingPage() {
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{TEXT_FIELDS.map(field => ( {TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label> <label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span> <span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={currentValue(field.key)} value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)} onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-72 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" 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"
/> />
{config[field.key]?.source === 'admin' && ( {config[field.key]?.source === 'admin' && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default"> <button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
@@ -22,11 +22,11 @@ interface ConfigData {
devMode?: boolean; devMode?: boolean;
} }
export default function AdminDashboardPage() { export function DashboardTab() {
const [status, setStatus] = useState<AdminStatus | null>(null); const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]); const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null); const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null); const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]); const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0); const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0); const [themeCount, setThemeCount] = useState(0);
@@ -96,7 +96,9 @@ export default function AdminDashboardPage() {
const sources = await adminConfigRes.json(); const sources = await adminConfigRes.json();
setConfigSources(sources); setConfigSources(sources);
const sessionSecret = sources?.sessionSecret; const sessionSecret = sources?.sessionSecret;
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { // 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.'); w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
} }
const adminPassword = sources?.adminPassword; const adminPassword = sources?.adminPassword;
@@ -112,7 +114,6 @@ export default function AdminDashboardPage() {
return ( return (
<div className="max-w-3xl space-y-8"> <div className="max-w-3xl space-y-8">
{/* Warnings */}
{warnings.map((msg, i) => ( {warnings.map((msg, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4"> <div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" /> <AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
@@ -120,19 +121,6 @@ export default function AdminDashboardPage() {
</div> </div>
))} ))}
{status && !status.lastLogin && (
<div className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-warning">First login detected</p>
<p className="text-sm text-warning/80 mt-0.5">
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
</p>
</div>
</div>
)}
{/* Server Info */}
<SettingsSection title="Server" description="Application and connection details"> <SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application"> <SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span> <span className="text-sm text-foreground">{config?.appName || '-'}</span>
@@ -157,7 +145,6 @@ export default function AdminDashboardPage() {
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
{/* Features */}
<SettingsSection title="Features" description="Enabled integrations and modules"> <SettingsSection title="Features" description="Enabled integrations and modules">
<SettingItem label="Admin Panel" description="Administrative access to server configuration"> <SettingItem label="Admin Panel" description="Administrative access to server configuration">
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled /> <ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
@@ -173,7 +160,6 @@ export default function AdminDashboardPage() {
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
{/* Accounts */}
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days"> <SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window"> <SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span> <span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
@@ -183,7 +169,6 @@ export default function AdminDashboardPage() {
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
{/* Extensions */}
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules"> <SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins"> <SettingItem label="Plugins">
<span className="text-sm text-foreground">{pluginCount}</span> <span className="text-sm text-foreground">{pluginCount}</span>
@@ -196,7 +181,6 @@ export default function AdminDashboardPage() {
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
{/* Recent Activity */}
<SettingsSection title="Recent Activity" description="Latest administrative actions"> <SettingsSection title="Recent Activity" description="Latest administrative actions">
{recentActivity.length === 0 ? ( {recentActivity.length === 0 ? (
<div className="py-4 text-sm text-muted-foreground"> <div className="py-4 text-sm text-muted-foreground">
@@ -5,7 +5,7 @@ import { RefreshCw } from 'lucide-react';
import type { AuditEntry } from '@/lib/admin/types'; import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
export default function AdminLogsPage() { export function LogsTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]); const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -33,8 +33,8 @@ export default function AdminLogsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1> <h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p> <p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div> </div>
@@ -47,12 +47,11 @@ export default function AdminLogsPage() {
</button> </button>
</div> </div>
{/* Filter */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<select <select
value={actionFilter} value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }} onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All actions</option> <option value="">All actions</option>
<option value="admin.login">Login</option> <option value="admin.login">Login</option>
@@ -66,15 +65,41 @@ export default function AdminLogsPage() {
</select> </select>
</div> </div>
{/* Table */} <div className="sm:hidden space-y-2">
<div className="border border-border rounded-lg overflow-hidden"> {loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-muted/30"> <tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th> <th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
@@ -110,7 +135,6 @@ export default function AdminLogsPage() {
</table> </table>
</div> </div>
{/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -2,8 +2,11 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; 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 { interface Extension {
slug: string; slug: string;
@@ -18,6 +21,9 @@ interface Extension {
minAppVersion: string | null; minAppVersion: string | null;
latestVersion: string | null; latestVersion: string | null;
installed: boolean; installed: boolean;
installedVersion: string | null;
iconUrl: string | null;
bannerUrl: string | null;
author: { author: {
displayName: string; displayName: string;
githubLogin: string; githubLogin: string;
@@ -36,7 +42,7 @@ interface SearchResult {
type TypeFilter = 'all' | 'plugin' | 'theme'; type TypeFilter = 'all' | 'plugin' | 'theme';
export default function AdminMarketplacePage() { export function MarketplaceTab() {
const [extensions, setExtensions] = useState<Extension[]>([]); const [extensions, setExtensions] = useState<Extension[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@@ -82,7 +88,6 @@ export default function AdminMarketplacePage() {
fetchExtensions(); fetchExtensions();
}, [fetchExtensions]); }, [fetchExtensions]);
// Debounced search
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
@@ -93,6 +98,15 @@ export default function AdminMarketplacePage() {
}, [searchInput]); }, [searchInput]);
async function handleInstall(ext: Extension) { 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); setInstalling(ext.slug);
setMessage(null); setMessage(null);
@@ -102,7 +116,7 @@ export default function AdminMarketplacePage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: ext.slug, slug: ext.slug,
version: ext.latestVersion || '1.0.0', version: targetVersion,
type: ext.type, type: ext.type,
}), }),
}); });
@@ -111,14 +125,22 @@ export default function AdminMarketplacePage() {
if (res.ok) { if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); setMessage({
// Mark as installed in the UI type: 'success',
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); 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 { } else {
setMessage({ type: 'error', text: data.error || 'Installation failed' }); setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(null); setInstalling(null);
} }
@@ -141,9 +163,8 @@ export default function AdminMarketplacePage() {
</div> </div>
)} )}
{/* Search & Filters */} <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3"> <div className="relative flex-1 min-w-0">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" type="text"
@@ -153,7 +174,7 @@ export default function AdminMarketplacePage() {
className="w-full h-9 pl-9 pr-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" className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/> />
</div> </div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5"> <div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
{(['all', 'plugin', 'theme'] as const).map((t) => ( {(['all', 'plugin', 'theme'] as const).map((t) => (
<button <button
key={t} key={t}
@@ -170,7 +191,6 @@ export default function AdminMarketplacePage() {
</div> </div>
</div> </div>
{/* Error State */}
{error && ( {error && (
<div className="border border-border rounded-lg p-12 text-center"> <div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" /> <Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
@@ -187,7 +207,6 @@ export default function AdminMarketplacePage() {
</div> </div>
)} )}
{/* Loading State */}
{loading && !error && ( {loading && !error && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
@@ -195,7 +214,6 @@ export default function AdminMarketplacePage() {
</div> </div>
)} )}
{/* Empty State */}
{!loading && !error && extensions.length === 0 && ( {!loading && !error && extensions.length === 0 && (
<div className="border border-border rounded-lg p-12 text-center"> <div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" /> <Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
@@ -208,7 +226,6 @@ export default function AdminMarketplacePage() {
</div> </div>
)} )}
{/* Extension Grid */}
{!loading && !error && extensions.length > 0 && ( {!loading && !error && extensions.length > 0 && (
<> <>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@@ -225,7 +242,6 @@ export default function AdminMarketplacePage() {
))} ))}
</div> </div>
{/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2"> <div className="flex items-center justify-center gap-2 pt-2">
<button <button
@@ -264,14 +280,37 @@ function ExtensionCard({
}) { }) {
const isPlugin = extension.type === 'plugin'; const isPlugin = extension.type === 'plugin';
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; 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 ( return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors"> <div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
{extension.bannerUrl && (
<Link href={previewHref} className="block focus:outline-none">
<img
src={extension.bannerUrl}
alt=""
className="block h-24 w-full object-cover border-b border-border"
loading="lazy"
/>
</Link>
)}
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg"> <Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
{/* Header */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{isPlugin ? ( {extension.iconUrl ? (
<img
src={extension.iconUrl}
alt=""
className="w-10 h-10 object-cover"
loading="lazy"
/>
) : isPlugin ? (
<Puzzle className="w-5 h-5 text-muted-foreground" /> <Puzzle className="w-5 h-5 text-muted-foreground" />
) : ( ) : (
<SwatchBook className="w-5 h-5 text-muted-foreground" /> <SwatchBook className="w-5 h-5 text-muted-foreground" />
@@ -303,12 +342,10 @@ function ExtensionCard({
</div> </div>
</div> </div>
{/* Description */}
<p className="text-xs text-muted-foreground mt-3 line-clamp-2"> <p className="text-xs text-muted-foreground mt-3 line-clamp-2">
{extension.description} {extension.description}
</p> </p>
{/* Tags */}
{extension.tags && extension.tags.length > 0 && ( {extension.tags && extension.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3"> <div className="flex flex-wrap gap-1 mt-3">
{extension.tags.slice(0, 3).map(tag => ( {extension.tags.slice(0, 3).map(tag => (
@@ -319,7 +356,6 @@ function ExtensionCard({
</div> </div>
)} )}
{/* Footer (download count + permissions) */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border"> <div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -339,13 +375,37 @@ function ExtensionCard({
</div> </div>
</Link> </Link>
{/* Quick install button (sits over the link, stops navigation) */} <div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
<div className="px-4 pb-4 -mt-1"> {extension.installed && updateAvailable ? (
{extension.installed ? ( <button
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"> onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ArrowUpCircle className="w-3 h-3" />
)}
Update to v{extension.latestVersion}
</button>
) : extension.installed ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
Installed Installed
</span> </span>
) : versionMismatch ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
>
<AlertTriangle className="w-3 h-3" />
Requires v{extension.minAppVersion}+
</span>
) : ( ) : (
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
interface ConfigField { interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
@@ -32,9 +32,12 @@ interface PluginInfo {
configSchema?: Record<string, ConfigField>; configSchema?: Record<string, ConfigField>;
} }
export default function PluginConfigPage() { interface Props {
const params = useParams(); pluginId: string;
const pluginId = params.id as string; onBack: () => void;
}
export function PluginConfigPanel({ pluginId, onBack }: Props) {
const [plugin, setPlugin] = useState<PluginInfo | null>(null); const [plugin, setPlugin] = useState<PluginInfo | null>(null);
const [config, setConfig] = useState<PluginConfig>({}); const [config, setConfig] = useState<PluginConfig>({});
const [formValues, setFormValues] = useState<Record<string, string>>({}); const [formValues, setFormValues] = useState<Record<string, string>>({});
@@ -44,11 +47,32 @@ export default function PluginConfigPage() {
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => { 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(); fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps return () => { cancelled = true; };
}, [pluginId]); }, [pluginId]);
// Initialize form values from config + schema defaults when data loads
useEffect(() => { useEffect(() => {
if (!plugin?.configSchema) return; if (!plugin?.configSchema) return;
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
@@ -65,33 +89,11 @@ export default function PluginConfigPage() {
setFormValues(initial); setFormValues(initial);
}, [plugin, config]); }, [plugin, config]);
async function fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
apiFetch('/api/admin/plugins'),
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
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 {
setLoading(false);
}
}
async function handleSaveAll() { async function handleSaveAll() {
if (!plugin?.configSchema) return; if (!plugin?.configSchema) return;
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
// Validate required fields
for (const [key, field] of Object.entries(plugin.configSchema)) { for (const [key, field] of Object.entries(plugin.configSchema)) {
if (field.required && !formValues[key]?.trim()) { if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` }); setMessage({ type: 'error', text: `"${field.label}" is required` });
@@ -101,22 +103,18 @@ export default function PluginConfigPage() {
} }
try { try {
// Save each changed field
let hasError = false; let hasError = false;
for (const [key, field] of Object.entries(plugin.configSchema)) { for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? ''; const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : ''; const oldVal = config[key] !== undefined ? String(config[key]) : '';
// Skip unchanged fields (and skip secret fields that show as empty when they have a stored value)
if (newVal === oldVal) continue; if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue; if (field.type === 'secret' && !newVal && config[key]) continue;
// Convert types
let value: unknown = newVal; let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true'; if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal); else if (field.type === 'number') value = Number(newVal);
// Delete if clearing a non-required field
if (!newVal && !field.required) { if (!newVal && !field.required) {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, { const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE', method: 'DELETE',
@@ -166,9 +164,13 @@ export default function PluginConfigPage() {
if (!plugin) { if (!plugin) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"> <button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Plugins <ArrowLeft className="w-4 h-4" /> Back to Plugins
</Link> </button>
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p> <p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
</div> </div>
); );
@@ -180,9 +182,14 @@ export default function PluginConfigPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"> <button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
aria-label="Back to Plugins"
>
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</Link> </button>
<div> <div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2"> <h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" /> <Puzzle className="w-5 h-5" />
@@ -281,6 +288,27 @@ export default function PluginConfigPage() {
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p> <p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div> </div>
)} )}
<PluginAdminSection pluginId={pluginId} />
</div>
);
}
/**
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
* and the host gives it a dedicated iframe inside the admin panel.
*/
function PluginAdminSection({ pluginId }: { pluginId: string }) {
const offers = usePluginSlotOffers('admin-plugin-page');
const offer = offers.find((o) => o.pluginId === pluginId);
if (!offer) return null;
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Plugin admin panel
</div>
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
</div> </div>
); );
} }
@@ -1,11 +1,11 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react'; import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types'; import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { PluginConfigPanel } from './plugin-config-panel';
interface PluginEntry { interface PluginEntry {
id: string; id: string;
@@ -21,7 +21,7 @@ interface PluginEntry {
updatedAt: string; updatedAt: string;
} }
export default function AdminPluginsPage() { export function PluginsTab() {
const [plugins, setPlugins] = useState<PluginEntry[]>([]); const [plugins, setPlugins] = useState<PluginEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -30,6 +30,7 @@ export default function AdminPluginsPage() {
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY }); const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false); const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false); const [savingPolicy, setSavingPolicy] = useState(false);
const [configuringId, setConfiguringId] = useState<string | null>(null);
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []); useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
@@ -153,7 +154,6 @@ export default function AdminPluginsPage() {
async function toggleForceEnabled(id: string, forceEnabled: boolean) { async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null); setMessage(null);
// If force-enabling, also ensure the plugin is enabled
const body: Record<string, unknown> = { id, forceEnabled }; const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true; if (forceEnabled) body.enabled = true;
@@ -165,7 +165,6 @@ export default function AdminPluginsPage() {
if (res.ok) { if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p)); setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
// Also update policy
setPolicy(prev => { setPolicy(prev => {
const current = prev.forceEnabledPlugins || []; const current = prev.forceEnabledPlugins || [];
return { return {
@@ -252,6 +251,10 @@ export default function AdminPluginsPage() {
} }
} }
if (configuringId) {
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
}
if (loading) { if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>; return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
} }
@@ -262,12 +265,12 @@ export default function AdminPluginsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1> <h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p> <p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{policyDirty && ( {policyDirty && (
<button <button
onClick={handleSavePolicy} onClick={handleSavePolicy}
@@ -299,7 +302,6 @@ export default function AdminPluginsPage() {
</div> </div>
)} )}
{/* Plugin Policy */}
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -309,7 +311,7 @@ export default function AdminPluginsPage() {
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p> <p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Plugins Enabled</span> <span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p> <p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
@@ -320,7 +322,7 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">User Plugin Uploads</span> <span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
@@ -331,7 +333,7 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Require Admin Approval</span> <span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p> <p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
@@ -342,9 +344,8 @@ export default function AdminPluginsPage() {
</button> </button>
</div> </div>
{/* Force enable / disable all */}
{plugins.length > 0 && ( {plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<span className="text-sm text-foreground">Force Enable / Disable All</span> <span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p> <p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
@@ -370,7 +371,6 @@ export default function AdminPluginsPage() {
</div> </div>
</div> </div>
{/* Deployed Plugins */}
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -388,9 +388,9 @@ export default function AdminPluginsPage() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{plugins.map(plugin => ( {plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4"> <div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span> <span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span> <span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
@@ -419,13 +419,14 @@ export default function AdminPluginsPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <button
href={`/admin/plugins/${plugin.id}`} type="button"
onClick={() => setConfiguringId(plugin.id)}
title="Configure" title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
> >
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
</Link> </button>
<button <button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)} onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'} title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
@@ -6,7 +6,6 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
// Feature gates managed on their own admin pages (excluded from this list)
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = { const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
@@ -31,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] }, { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, { 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: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
@@ -44,7 +43,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
]; ];
export default function AdminPolicyPage() { export function PolicyTab() {
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY }); const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -132,8 +131,8 @@ export default function AdminPolicyPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1> <h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p> <p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div> </div>
@@ -155,7 +154,6 @@ export default function AdminPolicyPage() {
</div> </div>
)} )}
{/* Feature Gates */}
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2> <h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
@@ -170,13 +168,13 @@ export default function AdminPolicyPage() {
const { label, description } = meta; const { label, description } = meta;
const enabled = policy.features[key]; const enabled = policy.features[key];
return ( return (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p> <p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div> </div>
<button onClick={() => toggleFeature(key)} <button onClick={() => toggleFeature(key)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
@@ -185,7 +183,6 @@ export default function AdminPolicyPage() {
</div> </div>
</div> </div>
{/* Setting Restrictions */}
{categories.map(category => ( {categories.map(category => (
<div key={category} className="border border-border rounded-lg"> <div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
@@ -195,9 +192,9 @@ export default function AdminPolicyPage() {
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {}; const restriction = policy.restrictions[setting.key] || {};
return ( return (
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4"> <div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span> <span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)} <input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" /> className="rounded border-input" />
@@ -3,13 +3,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from 'lucide-react'; import { Save, RotateCcw, Loader2 } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry { interface ConfigEntry {
value: unknown; value?: unknown;
source: 'admin' | 'env' | 'default'; source: 'admin' | 'env' | 'default';
hasValue?: boolean;
} }
export default function AdminSettingsPage() { export function SettingsTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({}); const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({}); const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -86,8 +89,8 @@ export default function AdminSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1> <h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p> <p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div> </div>
@@ -109,7 +112,6 @@ export default function AdminSettingsPage() {
</div> </div>
)} )}
{/* General */}
<SettingsSection title="General"> <SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} /> <TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" /> <TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
@@ -125,13 +127,36 @@ export default function AdminSettingsPage() {
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} /> <ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection> </SettingsSection>
{/* Logging */} <SettingsSection title="JMAP Servers (multi-server)">
<ToggleSetting
label="Auto-pick server by email domain"
description="When users type their email, automatically select the matching server from the list below."
configKey="jmapServerAutoPickByDomain"
value={currentValue('jmapServerAutoPickByDomain') as boolean}
source={config.jmapServerAutoPickByDomain?.source}
onChange={handleChange}
onRevert={handleRevert}
/>
<JmapServersSection
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
source={config.jmapServers?.source}
onChange={(next) => handleChange('jmapServers', next)}
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</p>
</div>
)}
</SettingsSection>
<SettingsSection title="Logging"> <SettingsSection title="Logging">
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} /> <SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} /> <SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection> </SettingsSection>
{/* Settings Sync */}
<SettingsSection title="Settings Sync"> <SettingsSection title="Settings Sync">
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} /> <ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection> </SettingsSection>
@@ -166,21 +191,21 @@ function TextSetting({ label, configKey, value, source, onChange, onRevert, plac
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground whitespace-nowrap">{label}</label> <label className="text-sm text-foreground">{label}</label>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 w-full sm:w-auto">
<input <input
type="text" type="text"
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)} onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder} placeholder={placeholder}
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" 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' && ( {source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default"> <button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
</button> </button>
)} )}
@@ -194,7 +219,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
@@ -202,7 +227,7 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
</div> </div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={() => onChange(configKey, !value)} onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
@@ -224,12 +249,12 @@ function SelectSetting({ label, configKey, value, source, options, onChange, onR
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) { }) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span> <span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} /> <SourceBadge source={source} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<select <select
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)} onChange={(e) => onChange(configKey, e.target.value)}
@@ -29,7 +29,7 @@ function timeAgo(iso: string | null): string {
return `${days} days ago`; return `${days} days ago`;
} }
export default function AdminTelemetryPage() { export function TelemetryTab() {
const [status, setStatus] = useState<TelemetryStatus | null>(null); const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
@@ -114,7 +114,7 @@ export default function AdminTelemetryPage() {
const isOn = status.consent === 'on'; const isOn = status.consent === 'on';
return ( return (
<div className="max-w-3xl mx-auto p-6 space-y-6"> <div className="space-y-6">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1> <h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -133,8 +133,8 @@ export default function AdminTelemetryPage() {
</header> </header>
<section className="rounded-lg border p-4 space-y-3"> <section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div className="min-w-0">
<div className="font-medium">Status</div> <div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
@@ -192,22 +192,22 @@ export default function AdminTelemetryPage() {
<section className="rounded-lg border p-4 space-y-3"> <section className="rounded-lg border p-4 space-y-3">
<div className="font-medium">Endpoint</div> <div className="font-medium">Endpoint</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector Where heartbeats are sent. Defaults to the project&apos;s collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending. (open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p> </p>
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="url" type="url"
value={endpointDraft} value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)} onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint} placeholder={status.defaultEndpoint}
className="flex-1 px-3 py-1.5 rounded-md border bg-background" className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/> />
<button <button
type="button" type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint} disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()} onClick={() => void saveEndpoint()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1" className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
> >
<Save className="h-4 w-4" /> Save <Save className="h-4 w-4" /> Save
</button> </button>
@@ -215,8 +215,8 @@ export default function AdminTelemetryPage() {
</section> </section>
<section className="rounded-lg border p-4 space-y-3"> <section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div className="min-w-0">
<div className="font-medium">Payload preview</div> <div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now. Exactly what the next heartbeat would send from this install, right now.
@@ -226,7 +226,7 @@ export default function AdminTelemetryPage() {
type="button" type="button"
disabled={busy === 'send' || !isOn} disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()} onClick={() => void sendNow()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1" className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
> >
<Send className="h-4 w-4" /> Send now <Send className="h-4 w-4" /> Send now
</button> </button>
@@ -25,7 +25,7 @@ interface ThemeEntry {
updatedAt: string; updatedAt: string;
} }
export default function AdminThemesPage() { export function ThemesTab() {
const [themes, setThemes] = useState<ThemeEntry[]>([]); const [themes, setThemes] = useState<ThemeEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -307,12 +307,12 @@ export default function AdminThemesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1> <h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p> <p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{policyDirty && ( {policyDirty && (
<button <button
onClick={handleSavePolicy} onClick={handleSavePolicy}
@@ -344,7 +344,6 @@ export default function AdminThemesPage() {
</div> </div>
)} )}
{/* Theme Policy */}
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -355,38 +354,35 @@ export default function AdminThemesPage() {
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{/* Master toggle */} <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="min-w-0">
<div>
<span className="text-sm text-foreground">Themes Enabled</span> <span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div> </div>
<button onClick={toggleThemesEnabled} <button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
{/* User uploads toggle */} <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="min-w-0">
<div>
<span className="text-sm text-foreground">User Theme Uploads</span> <span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p> <p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div> </div>
<button onClick={toggleUserThemeUploads} <button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}> className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} /> <span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button> </button>
</div> </div>
{/* Force enable / disable all */}
{themes.length > 0 && ( {themes.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span> <span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p> <p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button <button
onClick={forceEnableAll} onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors" className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
@@ -405,17 +401,16 @@ export default function AdminThemesPage() {
</div> </div>
)} )}
{/* Default Theme */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span> <span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p> <p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div> </div>
<select <select
value={policy.themePolicy?.defaultThemeId || ''} value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)} onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground" className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
> >
<option value="">System Default</option> <option value="">System Default</option>
<optgroup label="Built-in"> <optgroup label="Built-in">
@@ -438,7 +433,6 @@ export default function AdminThemesPage() {
</div> </div>
</div> </div>
{/* Built-in themes */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span> <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
@@ -457,7 +451,6 @@ export default function AdminThemesPage() {
</div> </div>
</div> </div>
{/* Admin-deployed themes */}
{themes.length > 0 && ( {themes.length > 0 && (
<div className="px-4 py-3"> <div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span> <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
@@ -480,7 +473,6 @@ export default function AdminThemesPage() {
</div> </div>
</div> </div>
{/* Deployed Themes */}
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -498,9 +490,9 @@ export default function AdminThemesPage() {
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{themes.map(theme => ( {themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4"> <div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span> <span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span> <span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}> <span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
+237
View File
@@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity) {
switch (severity) {
case 'security':
return {
label: 'Security update',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'deprecated':
return {
label: 'Deprecated',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'normal':
return {
label: 'Update available',
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
Icon: AlertTriangle,
};
case 'unknown':
return {
label: 'Unknown',
className: 'bg-muted text-muted-foreground border-border',
Icon: AlertTriangle,
};
case 'none':
default:
return {
label: 'Up to date',
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
Icon: CheckCircle2,
};
}
}
export function VersionTab() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setCheckResult(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setCheckResult({
ok: !!j.ok,
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally {
setChecking(false);
}
}
if (loading || !data) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=auth');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=branding');
}
+479
View File
@@ -0,0 +1,479 @@
'use 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 'lucide-react';
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 } from '@/lib/browser-navigation';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
// hang we saw with both /admin/<segment> routes and ?tab= search params.
const NAV_GROUPS: ReadonlyArray<{
label: string;
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
}> = [
{
label: 'Overview',
items: [
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ tab: 'settings', label: 'Settings', icon: Settings },
{ tab: 'branding', label: 'Branding', icon: Palette },
{ tab: 'auth', label: 'Authentication', icon: Shield },
{ tab: 'policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
],
},
{
label: 'System',
items: [
{ tab: 'version', label: 'Version', icon: Package },
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
// Highlight the active tab only on /admin itself - on dynamic routes
// (e.g. /admin/plugins/[id]) no tab is "current".
const activeTab = pathname === '/admin' ? storeActiveTab : null;
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = 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 = 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;
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
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;
}
router.replace('/admin/login');
} 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');
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
// /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix();
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ tab, label, icon: Icon }) => {
const active = activeTab === tab;
const showDot = tab === 'version' && hasUpdate;
const handleClick = () => {
setActiveTab(tab);
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
// we still need a real navigation back to /admin so the page renders.
if (pathname !== '/admin') router.push('/admin');
};
return (
<button
key={tab}
type="button"
onClick={handleClick}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</button>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</>
);
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
<div className="w-7 h-7 mb-2" />
)}
<a
href={`${prefix}/`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/calendar`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/contacts`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href={`${prefix}/settings`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
<Settings className="w-[18px] h-[18px]" />
</a>
</div>
</nav>
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
{navContent}
</aside>
{/* Mobile drawer overlay */}
{mobileNavOpen && (
<div
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
aria-hidden={!mobileNavOpen}
>
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
<button
type="button"
onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
>
<X className="w-5 h-5" />
</button>
</div>
{navContent}
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 overflow-x-hidden">
{/* Mobile header */}
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div>
</main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href={`${prefix}/`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href={`${prefix}/calendar`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href={`${prefix}/contacts`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href={`${prefix}/settings`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div>
);
}
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
<div className="min-h-screen flex items-center justify-center bg-background px-4"> <div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
{logoUrl ? ( {logoUrl ? (
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" /> <img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
) : ( ) : (
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-primary" /> <Shield className="w-6 h-6 text-primary" />
)}
</div> </div>
)}
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1> <h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p> <p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
</div> </div>
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=logs');
}
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import {
ArrowLeft, ArrowLeft,
ArrowUpCircle,
Download, Download,
Loader2, Loader2,
Puzzle, Puzzle,
@@ -21,6 +22,9 @@ import {
ChevronUp, ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; 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 { interface PreviewData {
extension: { extension: {
@@ -37,6 +41,8 @@ interface PreviewData {
githubRepo: string | null; githubRepo: string | null;
license: string | null; license: string | null;
minAppVersion: string | null; minAppVersion: string | null;
iconUrl: string | null;
bannerUrl: string | null;
author: { author: {
displayName: string; displayName: string;
githubLogin: string; githubLogin: string;
@@ -68,6 +74,7 @@ interface PreviewData {
error: string | null; error: string | null;
}; };
installed: boolean; installed: boolean;
installedVersion: string | null;
} }
const RISKY_PERMISSIONS = new Set([ const RISKY_PERMISSIONS = new Set([
@@ -113,6 +120,8 @@ export default function MarketplacePreviewPage() {
async function handleInstall() { async function handleInstall() {
if (!data) return; if (!data) return;
const isUpdate = data.installed;
const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true); setInstalling(true);
setMessage(null); setMessage(null);
try { try {
@@ -121,20 +130,25 @@ export default function MarketplacePreviewPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: data.extension.slug, slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0', version: targetVersion,
type: data.extension.type, type: data.extension.type,
}), }),
}); });
const body = await res.json(); const body = await res.json();
if (res.ok) { if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` }); setMessage({
setData(prev => prev ? { ...prev, installed: true } : prev); 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 { } else {
setMessage({ type: 'error', text: body.error || 'Installation failed' }); setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(false); setInstalling(false);
} }
@@ -198,6 +212,12 @@ export default function MarketplacePreviewPage() {
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || []; const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined; const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
const updateAvailable = data.installed
&& !!data.installedVersion
&& !!ext.latestVersion
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
&& !versionMismatch;
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
@@ -209,24 +229,55 @@ export default function MarketplacePreviewPage() {
<ArrowLeft className="w-4 h-4" /> Back to Marketplace <ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link> </Link>
{/* Banner / hero */}
{ext.bannerUrl && (
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
<img
src={ext.bannerUrl}
alt=""
className="block w-full max-h-64 object-cover"
loading="lazy"
/>
</div>
)}
{/* Header */} {/* Header */}
<div className="flex items-start gap-4"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0"> <div className="flex items-start gap-4 flex-1 min-w-0">
{isPlugin ? ( <div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{ext.iconUrl ? (
<img
src={ext.iconUrl}
alt=""
className="w-14 h-14 object-cover"
loading="lazy"
/>
) : isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" /> <Puzzle className="w-7 h-7 text-muted-foreground" />
) : ( ) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" /> <SwatchBook className="w-7 h-7 text-muted-foreground" />
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground truncate">{ext.name}</h1> <h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />} {ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && ( {data.installed && !updateAvailable && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"> <span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> Installed <Check className="w-3 h-3" /> Installed
</span> </span>
)} )}
{data.installed && updateAvailable && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
>
<ArrowUpCircle className="w-3 h-3" /> Update available
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap"> <div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
@@ -247,11 +298,23 @@ export default function MarketplacePreviewPage() {
</span> </span>
</div> </div>
</div> </div>
</div>
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? ( {data.installed ? (
<> <>
{updateAvailable && (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
Update to v{ext.latestVersion}
</button>
)}
<Link <Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'} href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors" className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
@@ -271,8 +334,11 @@ export default function MarketplacePreviewPage() {
) : ( ) : (
<button <button
onClick={handleInstall} onClick={handleInstall}
disabled={installing || !!bundle.error} disabled={installing || !!bundle.error || versionMismatch}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors" title={versionMismatch
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
: undefined}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />} {installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Install Install
@@ -287,6 +353,18 @@ export default function MarketplacePreviewPage() {
</div> </div>
)} )}
{versionMismatch && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Update Bulwark to install this extension</p>
<p className="text-xs mt-0.5 opacity-90">
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
</p>
</div>
</div>
)}
{bundle.error && ( {bundle.error && (
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300"> <div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=marketplace');
}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
import { DashboardTab } from './_tabs/dashboard';
import { SettingsTab } from './_tabs/settings';
import { BrandingTab } from './_tabs/branding';
import { AuthTab } from './_tabs/auth';
import { PolicyTab } from './_tabs/policy';
import { PluginsTab } from './_tabs/plugins';
import { ThemesTab } from './_tabs/themes';
import { MarketplaceTab } from './_tabs/marketplace';
import { VersionTab } from './_tabs/version';
import { TelemetryTab } from './_tabs/telemetry';
import { LogsTab } from './_tabs/logs';
export default function AdminPage() {
const activeTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
// Honour deep links from the old route structure: /admin?tab=settings
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
// once on mount, then strips the param so the URL stays at /admin and
// subsequent tab clicks don't accumulate query strings.
useEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const fromUrl = url.searchParams.get('tab');
if (isAdminTab(fromUrl)) {
setActiveTab(fromUrl);
url.searchParams.delete('tab');
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
}
}, [setActiveTab]);
switch (activeTab) {
case 'dashboard': return <DashboardTab />;
case 'settings': return <SettingsTab />;
case 'branding': return <BrandingTab />;
case 'auth': return <AuthTab />;
case 'policy': return <PolicyTab />;
case 'plugins': return <PluginsTab />;
case 'themes': return <ThemesTab />;
case 'marketplace': return <MarketplaceTab />;
case 'version': return <VersionTab />;
case 'telemetry': return <TelemetryTab />;
case 'logs': return <LogsTab />;
}
}
+7
View File
@@ -0,0 +1,7 @@
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');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=plugins');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=policy');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=settings');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=telemetry');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=themes');
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=version');
}
+12 -4
View File
@@ -1,10 +1,11 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import "./globals.css"; import { configManager } from "@/lib/admin/config-manager";
import "../globals.css";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -16,8 +17,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const faviconUrl = process.env.FAVICON_URL; await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
return { return {
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
@@ -30,7 +38,7 @@ export async function generateMetadata(): Promise<Metadata> {
formatDetection: { formatDetection: {
telephone: false, telephone: false,
}, },
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}), icons: { icon: faviconUrl },
}; };
} }
+16 -7
View File
@@ -2,34 +2,43 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
export default function NotFound() { export default function NotFound() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
// Don't redirect admin routes to the webmail login page const prefix = getPathPrefix();
const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'); // Don't redirect admin routes to the webmail login page. Admin paths
// are mounted relative to the deployment prefix, so account for it.
const adminBase = `${prefix}/admin`;
const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
if (!isAdminRoute) { if (!isAdminRoute) {
window.location.href = "/login"; window.location.href = `${prefix}/login`;
} }
} }
}, [isAuthenticated]); }, [isAuthenticated]);
if (!isAuthenticated) { if (!isAuthenticated) {
// Allow admin routes to render the 404 without redirecting let isAdmin = false;
const isAdmin = typeof window !== 'undefined' && if (typeof window !== 'undefined') {
(window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/')); const prefix = getPathPrefix();
const adminBase = `${prefix}/admin`;
isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
}
if (!isAdmin) return null; if (!isAdmin) return null;
} }
const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4"> <div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1> <h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p> <p className="text-muted-foreground mb-6">This page could not be found.</p>
<a <a
href="/" href={`${prefix}/`}
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
> >
Go home Go home
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client";
export default async function MailtoProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <MailtoProtocolClient openingText={t("opening_mailto")} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { getTranslations } from "next-intl/server";
import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client";
export default async function WebcalProtocolPage() {
const t = await getTranslations("protocol_handlers");
return <WebcalProtocolClient openingText={t("opening_webcal")} />;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react';
export default function SetupLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Geist, Geist_Mono } from 'next/font/google';
import '../globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Plugin sandbox',
robots: { index: false, follow: false },
};
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ margin: 0, padding: 0, background: 'transparent' }}
>
{children}
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
// Next's injected hydration/chunk scripts. With force-static, those scripts
// render without a nonce and the strict sandbox CSP blocks them.
export const dynamic = 'force-dynamic';
export default function PluginSandboxPage() {
return <SandboxRuntime />;
}
-536
View File
@@ -1,536 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import {
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
User,
Shield,
UserPen,
PalmtreeIcon,
Calendar,
Filter,
FileText,
FolderOpen,
Tags,
HardDrive,
BookUser,
KeyRound,
PanelLeftClose,
Bell,
Puzzle,
LayoutGrid,
BookOpen,
PenLine,
EyeOff,
Languages,
Info,
Bug,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { LayoutSettings } from '@/components/settings/layout-settings';
import { LanguageSettings } from '@/components/settings/language-settings';
import { ReadingSettings } from '@/components/settings/reading-settings';
import { ComposingSettings } from '@/components/settings/composing-settings';
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { VacationSettings } from '@/components/settings/vacation-settings';
import { CalendarSettings } from '@/components/settings/calendar-settings';
import { CalendarManagementSettings } from '@/components/settings/calendar-management-settings';
import { AddressBookManagementSettings } from '@/components/settings/address-book-management-settings';
import { FilterSettings } from '@/components/settings/filter-settings';
import { TemplateSettings } from '@/components/settings/template-settings';
import { AboutDataSettings } from '@/components/settings/about-data-settings';
import { DebugSettings } from '@/components/settings/debug-settings';
import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
type Tab =
| 'account'
| 'language'
| 'notifications'
| 'appearance'
| 'layout'
| 'reading'
| 'composing'
| 'identities'
| 'vacation'
| 'filters'
| 'templates'
| 'folders'
| 'keywords'
| 'security'
| 'encryption'
| 'content_senders'
| 'calendar'
| 'contacts'
| 'files'
| 'sidebar_apps'
| 'about_data'
| 'themes'
| 'plugins'
| 'debug';
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
experimental?: boolean;
}
const tabIcons: Record<Tab, LucideIcon> = {
account: User,
language: Languages,
notifications: Bell,
appearance: Palette,
layout: LayoutGrid,
reading: BookOpen,
composing: PenLine,
identities: UserPen,
vacation: PalmtreeIcon,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
security: Shield,
encryption: KeyRound,
content_senders: EyeOff,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
sidebar_apps: PanelLeftClose,
about_data: Info,
themes: Palette,
plugins: Puzzle,
debug: Bug,
};
const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced'];
// Map legacy tab IDs to current ones; runs once on read of localStorage.
const LEGACY_TAB_MAP: Record<string, Tab> = {
email: 'reading',
advanced: 'about_data',
};
function readPersistedTab(): Tab {
try {
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
if (saved in LEGACY_TAB_MAP) {
const migrated = LEGACY_TAB_MAP[saved];
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
} catch {
return 'appearance';
}
}
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [mobileShowContent, setMobileShowContent] = useState(false);
const isDesktop = useIsDesktop();
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
try { const v = localStorage.getItem('settings-sidebar-width'); return v ? Number(v) : 256; } catch { return 256; }
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
// Check auth on mount skip when already authenticated so that navigating
// between routes doesn't retrigger checkAuth's transient `{ client: null,
// isLoading: true }` reset, which was flashing the spinner on every nav.
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && state.client) {
setInitialCheckDone(true);
return;
}
checkAuth().finally(() => {
setInitialCheckDone(true);
});
}, [checkAuth]);
// Listen for tab change events from child components (with legacy migration)
useEffect(() => {
const handler = (e: Event) => {
const raw = (e as CustomEvent).detail as string;
if (!raw) return;
const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab;
setActiveTab(tab);
try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ }
};
window.addEventListener('settings-tab-change', handler);
return () => window.removeEventListener('settings-tab-change', handler);
}, []);
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
if (!isAuthenticated) {
return null;
}
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: TabDef[] = [
// General
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
// Appearance
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' },
{ id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' },
// Mail
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
// Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []),
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
];
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
// If active tab is not in the visible list (e.g., feature disabled), fall back.
const isActiveVisible = tabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance';
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
setMobileShowContent(true);
}
};
const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
<>
{effectiveActiveTab === 'account' && <AccountSettings />}
{effectiveActiveTab === 'language' && <LanguageSettings />}
{effectiveActiveTab === 'notifications' && <NotificationSettings />}
{effectiveActiveTab === 'appearance' && <AppearanceSettings />}
{effectiveActiveTab === 'layout' && <LayoutSettings />}
{effectiveActiveTab === 'reading' && <ReadingSettings />}
{effectiveActiveTab === 'composing' && <ComposingSettings />}
{effectiveActiveTab === 'identities' && <IdentitySettings />}
{effectiveActiveTab === 'vacation' && <VacationSettings />}
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'templates' && <TemplateSettings />}
{effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{effectiveActiveTab === 'contacts' && <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>}
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
{effectiveActiveTab === 'sidebar_apps' && <SidebarAppsSettings />}
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
</>
);
// Mobile layout
if (!isDesktop) {
if (mobileShowContent) {
return (
<div className="flex flex-col h-dvh bg-background">
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileShowContent(false)}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="bg-card border border-border rounded-lg p-4">
{renderTabContent()}
</div>
</div>
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
return (
<div className="flex flex-col h-dvh bg-background">
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/')}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
<div className="px-5 pt-3 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
{tab.experimental && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning">
Experimental
</span>
)}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
))}
</div>
<div className="border-t border-border px-5 py-3">
<button
onClick={logout}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
>
<LogOut className="w-4 h-4" />
<span>{tSidebar('sign_out')}</span>
</button>
</div>
</div>
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
// Desktop layout
return (
<div className="flex h-dvh bg-background">
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={logout}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
</div>
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{!inlineApp && (
<>
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
>
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t('back_to_mail')}
</Button>
</div>
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
effectiveActiveTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
{tab.experimental && (
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/15 text-warning shrink-0">
Experimental
</span>
)}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem('settings-sidebar-width', String(settingsSidebarWidth));
}}
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6">
{renderTabContent()}
</div>
</div>
</div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
-288
View File
@@ -1,288 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import {
LayoutDashboard,
Settings,
Palette,
Shield,
Scale,
ScrollText,
LogOut,
KeyRound,
Puzzle,
SwatchBook,
Activity,
Mail,
Calendar,
BookUser,
HardDrive,
ArrowLeft,
Store,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
import { apiFetch } from '@/lib/browser-navigation';
const NAV_GROUPS = [
{
label: 'Overview',
items: [
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/branding', label: 'Branding', icon: Palette },
{ href: '/admin/auth', label: 'Authentication', icon: Shield },
{ href: '/admin/policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
{ href: '/admin/marketplace', label: 'Marketplace', icon: Store },
],
},
{
label: 'System',
items: [
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
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;
const stalwartAdmin = data.stalwartAdmin === true;
setIsStalwartAdmin(stalwartAdmin);
// If neither password-based admin nor Stalwart admin, redirect away
if (!data.enabled && !stalwartAdmin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin but not yet authenticated, auto-login
if (stalwartAdmin) {
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;
}
router.replace('/admin/login');
} 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');
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail */}
<nav className="w-14 bg-secondary flex flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
<div className="w-7 h-7 mb-2" />
)}
<a
href="/"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href="/calendar"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href="/contacts"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
<a
href="/files"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href="/settings"
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
<Settings className="w-[18px] h-[18px]" />
</a>
</div>
</nav>
{/* Admin Sidebar */}
<aside className="w-60 border-r border-border bg-secondary flex flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ href, label, icon: Icon }) => {
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{label}
</Link>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAdmin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto p-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel
</div>
) : authenticated ? (
children
) : null}
</div>
</main>
</div>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10)); const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
Binary file not shown.
+7 -4
View File
@@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { getConfigDir } from '@/lib/admin/paths';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
'.svg': 'image/svg+xml', '.svg': 'image/svg+xml',
@@ -38,11 +41,11 @@ export async function GET(
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
} }
const filePath = path.join(BRANDING_DIR, safe); const filePath = path.join(getBrandingDir(), safe);
// Ensure resolved path is still within BRANDING_DIR // Ensure resolved path is still within getBrandingDir()
const resolved = path.resolve(filePath); const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) { if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }); return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
} }
+10 -7
View File
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit'; import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises'; import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([ const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml', 'image/svg+xml',
@@ -41,7 +44,7 @@ function sanitizeFilename(name: string): string {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
}; };
const ext = extMap[file.type] || '.png'; const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`); const safeName = sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(BRANDING_DIR, safeName); const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists // Ensure branding directory exists
if (!existsSync(BRANDING_DIR)) { if (!existsSync(getBrandingDir())) {
await mkdir(BRANDING_DIR, { recursive: true }); await mkdir(getBrandingDir(), { recursive: true });
} }
// Write file to disk // Write file to disk
@@ -111,7 +114,7 @@ export async function POST(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico']; const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
let removed = false; let removed = false;
for (const ext of possibleExts) { for (const ext of possibleExts) {
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`); const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) { if (existsSync(filePath)) {
await unlink(filePath); await unlink(filePath);
removed = true; removed = true;
+1 -1
View File
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+47 -6
View File
@@ -2,21 +2,45 @@ import { NextRequest, NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit'; import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP } from '@/lib/admin/types'; import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
// raw value to the client.
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
/** /**
* GET /api/admin/config - Get full config with sources (admin-protected) * GET /api/admin/config - Get full config with sources (admin-protected)
*
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
* `value` omitted and a `hasValue` boolean instead. An admin session is
* enough to read every other config knob; the secrets themselves stay on
* the server so that an XSS or session-theft can't lift them in one
* request and forge admin/user session cookies offline.
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const config = configManager.getAllWithSources(); const config = configManager.getAllWithSources();
return NextResponse.json(config, { const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
for (const [key, entry] of Object.entries(config)) {
if (SENSITIVE_CONFIG_KEYS.has(key)) {
const v = entry.value;
const hasValue =
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
safe[key] = { source: entry.source, hasValue };
} else {
safe[key] = entry;
}
}
return NextResponse.json(safe, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch (error) { } catch (error) {
@@ -30,7 +54,7 @@ export async function GET() {
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -47,6 +71,23 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 }); return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
} }
// Normalize jmapServers: pass through the parser so invalid entries are
// rejected (bad ids, duplicate ids, non-HTTP URLs) before they're persisted.
if ('jmapServers' in updates) {
const incoming = updates.jmapServers;
if (incoming != null && !Array.isArray(incoming)) {
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
}
const sanitized = parseJmapServers(incoming);
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json({
error: 'One or more jmapServers entries are invalid (each needs a unique id, label, and HTTP(S) url).',
}, { status: 400 });
}
updates.jmapServers = sanitized;
}
// Get old values for audit // Get old values for audit
const oldValues: Record<string, unknown> = {}; const oldValues: Record<string, unknown> = {};
for (const key of Object.keys(updates)) { for (const key of Object.keys(updates)) {
@@ -68,7 +109,7 @@ export async function PATCH(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+24 -9
View File
@@ -7,8 +7,12 @@ import {
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
const MAX_PREVIEW_SOURCE_LEN = 100_000; const MAX_PREVIEW_SOURCE_LEN = 100_000;
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
* Lets admins audit what they're about to install before pressing the button. * Lets admins audit what they're about to install before pressing the button.
*/ */
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }, { params }: { params: Promise<{ slug: string }> },
) { ) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { slug } = await params; const { slug } = await params;
const directoryUrl = await getDirectoryUrl();
// 1. Extension metadata + screenshots + theme previews from the directory // 1. Extension metadata + screenshots + theme previews from the directory
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL); const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
const detailRes = await fetch(detailUrl.toString(), { const detailRes = await fetch(detailUrl.toString(), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
@@ -63,7 +68,7 @@ export async function GET(
try { try {
const bundleUrl = new URL( const bundleUrl = new URL(
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`, `/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
DIRECTORY_URL, directoryUrl,
); );
const bundleRes = await fetch(bundleUrl.toString(), { const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
@@ -144,14 +149,16 @@ export async function GET(
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installed = type === 'theme' const installedEntry = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug) ? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.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). // 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots) const screenshots = Array.isArray(extension.screenshots)
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({ ? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(), url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
altText: s.altText ?? null, altText: s.altText ?? null,
})) }))
: []; : [];
@@ -168,6 +175,11 @@ export async function GET(
})) }))
: []; : [];
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
return NextResponse.json( return NextResponse.json(
{ {
extension: { extension: {
@@ -184,6 +196,8 @@ export async function GET(
githubRepo: extension.githubRepo ?? null, githubRepo: extension.githubRepo ?? null,
license: extension.license ?? null, license: extension.license ?? null,
minAppVersion: extension.minAppVersion ?? null, minAppVersion: extension.minAppVersion ?? null,
iconUrl: fileUrl(extension.iconPath),
bannerUrl: fileUrl(extension.bannerPath),
author: extension.author ?? null, author: extension.author ?? null,
latestVersion, latestVersion,
versions, versions,
@@ -199,6 +213,7 @@ export async function GET(
error: bundleError, error: bundleError,
}, },
installed, installed,
installedVersion,
}, },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
); );
+140 -24
View File
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import { import {
savePlugin, savePlugin,
saveTheme, saveTheme,
getPlugin,
getTheme,
getPluginRegistry, getPluginRegistry,
getThemeRegistry, getThemeRegistry,
type ServerPlugin, type ServerPlugin,
@@ -12,13 +14,19 @@ import {
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import { import {
sanitizeFrameOrigins, sanitizeFrameOrigins,
sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache, invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins'; } from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
import { configManager } from '@/lib/admin/config-manager';
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org'; async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
}
/** /**
* GET /api/admin/marketplace - Search/browse the extension directory * GET /api/admin/marketplace - Search/browse the extension directory
@@ -26,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const directoryUrl = await getDirectoryUrl();
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const url = new URL('/api/v1/extensions', DIRECTORY_URL); const url = new URL('/api/v1/extensions', directoryUrl);
// Forward all search params // Forward all search params
for (const [key, value] of searchParams.entries()) { for (const [key, value] of searchParams.entries()) {
@@ -57,16 +66,32 @@ export async function GET(request: NextRequest) {
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); const installedPluginVersions = new Map(
const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); pluginRegistry.plugins.map(p => [p.id, p.version] as const),
);
const installedThemeVersions = new Map(
themeRegistry.themes.map(t => [t.id, t.version] as const),
);
const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
: null;
if (data.data) { if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({ data.data = data.data.map((ext: Record<string, unknown>) => {
const slug = ext.slug as string;
const installedVersion = ext.type === 'theme'
? installedThemeVersions.get(slug) ?? null
: installedPluginVersions.get(slug) ?? null;
return {
...ext, ...ext,
installed: ext.type === 'theme' iconUrl: fileUrl(ext.iconPath),
? installedThemes.has(ext.slug as string) bannerUrl: fileUrl(ext.bannerPath),
: installedPlugins.has(ext.slug as string), installed: installedVersion !== null,
})); installedVersion,
};
});
} }
return NextResponse.json(data, { return NextResponse.json(data, {
@@ -84,7 +109,7 @@ export async function GET(request: NextRequest) {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -99,7 +124,8 @@ export async function POST(request: NextRequest) {
} }
// Download the bundle from the directory // Download the bundle from the directory
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL); const directoryUrl = await getDirectoryUrl();
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
const bundleRes = await fetch(bundleUrl.toString(), { const bundleRes = await fetch(bundleUrl.toString(), {
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
}); });
@@ -155,6 +181,18 @@ export async function POST(request: NextRequest) {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Resolve and strictly validate the id used as a filename. Marketplace
// bundles are authored by a third-party publisher; without this an id
// like "../../foo" causes savePlugin/saveTheme to write outside the
// plugins/themes dir via path.join.
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
return NextResponse.json(
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
{ status: 400 },
);
}
if (type === 'theme') { if (type === 'theme') {
// Read theme.css // Read theme.css
const cssFile = zip.file(root + 'theme.css'); const cssFile = zip.file(root + 'theme.css');
@@ -173,22 +211,43 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings); warnings.push(...sanitized.warnings);
} }
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = { const theme: ServerTheme = {
id: (manifest.id as string) || slug, id: resolvedId,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
version: (manifest.version as string) || version, // Prefer the directory-published version (what we requested) over
// manifest.version. Publishers sometimes forget to bump the version
// inside the bundle's manifest.json; trusting it would make the
// update never appear to "stick" — the registry would keep showing
// the older version even after a successful update.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'], variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true, enabled: existingTheme?.enabled ?? true,
installedAt: now, ...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now, updatedAt: now,
}; };
await saveTheme(theme, css); await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip); await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
id: theme.id,
name: theme.name,
version: theme.version,
slug,
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, theme, warnings }); return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
} else { } else {
// Plugin installation // Plugin installation
// Read entrypoint JS // Read entrypoint JS
@@ -246,28 +305,85 @@ export async function POST(request: NextRequest) {
); );
} }
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
? (manifest.httpOrigins as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
)
: [];
if (droppedHttpOrigins.length > 0) {
warnings.push(
`Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
);
}
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
? (manifest.apiPostPaths as unknown[]).filter(
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
)
: [];
if (droppedApiPostPaths.length > 0) {
warnings.push(
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
);
}
const existingPlugin = await getPlugin(resolvedId);
const isUpdate = existingPlugin !== null;
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
id: (manifest.id as string) || slug, id: resolvedId,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
version: (manifest.version as string) || version, // See theme branch: trust the directory-published version, not
// manifest.version, so updates actually stick in the registry.
version: version || (manifest.version as string),
author: (manifest.author as string) || 'Unknown', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook', type: (manifest.type as string) || 'hook',
permissions, permissions,
entrypoint, entrypoint,
enabled: true, enabled: existingPlugin?.enabled ?? true,
installedAt: now, ...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now, updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(declaredFrameOrigins.length > 0 ...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins } ? { frameOrigins: declaredFrameOrigins }
: {}), : {}),
...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins }
: {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
invalidateFrameOriginsCache(); invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip); await auditLog(
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
{
id: plugin.id,
name: plugin.name,
version: plugin.version,
slug,
frameOrigins: declaredFrameOrigins,
httpOrigins: declaredHttpOrigins,
apiPostPaths: declaredApiPostPaths,
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, plugin, warnings }); return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
} }
} catch (error) { } catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
+1 -1
View File
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+85
View File
@@ -0,0 +1,85 @@
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 { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
*
* GET /api/admin/plugin-approvals → list all entries
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
*/
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const entries = await listApprovals();
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
if (b.decision !== 'approved' && b.decision !== 'denied') {
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
}
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
} catch (err) {
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
// AdminSessionPayload carries only role/iat/exp; we use a stable label
// for the audit trail rather than a per-user identity.
const adminUser = 'admin';
void result;
const ip = getClientIP(request);
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
await revokeApproval(pluginId, bundleHash);
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
return NextResponse.json({ ok: true });
} catch (err) {
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+41 -7
View File
@@ -1,5 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
import { signBytes } from '@/lib/admin/plugin-signing';
async function safeSign(code: string): Promise<string | null> {
try { return await signBytes(code); } catch { return null; }
}
/** /**
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
@@ -8,7 +14,7 @@ import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
* Only serves plugins that exist in the registry and are enabled. * Only serves plugins that exist in the registry and are enabled.
*/ */
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
@@ -19,6 +25,22 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
} }
// Dev plugins are read (and optionally bundled) straight from disk and
// served with no caching so every refresh picks up the latest source.
const devEntry = await getDevPlugin(id);
if (devEntry) {
const code = await readDevBundle(devEntry);
const signature = await safeSign(code);
const headers: Record<string, string> = {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store',
'ETag': `"${devEntry.plugin.bundleHash}"`,
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
};
if (signature) headers['X-Bundle-Signature'] = signature;
return new NextResponse(code, { headers });
}
const plugin = await getPlugin(id); const plugin = await getPlugin(id);
if (!plugin) { if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
@@ -33,13 +55,25 @@ export async function GET(
return NextResponse.json({ error: 'Bundle not found' }, { status: 404 }); return NextResponse.json({ error: 'Bundle not found' }, { status: 404 });
} }
return new NextResponse(code, { // Use the registry's bundleHash as the ETag so the browser can revalidate
headers: { // cheaply. Cache-Control: no-cache forces revalidation on every request,
// but a matching If-None-Match returns 304 with no body.
const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined;
const headers: Record<string, string> = {
'Content-Type': 'application/javascript; charset=utf-8', 'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=3600, must-revalidate', 'Cache-Control': 'private, no-cache, must-revalidate',
'Content-Length': String(Buffer.byteLength(code, 'utf-8')), };
}, if (etag) headers['ETag'] = etag;
});
const signature = await safeSign(code);
if (signature) headers['X-Bundle-Signature'] = signature;
if (etag && request.headers.get('if-none-match') === etag) {
return new NextResponse(null, { status: 304, headers });
}
headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8'));
return new NextResponse(code, { headers });
} catch { } catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
+30 -9
View File
@@ -1,9 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry'; import { getPlugin } from '@/lib/admin/plugin-registry';
import { getDevPlugin } from '@/lib/admin/plugin-dev';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session'; import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */
async function resolvePlugin(id: string) {
const registered = await getPlugin(id);
if (registered) return registered;
const dev = await getDevPlugin(id);
return dev?.plugin ?? null;
}
/** /**
* GET /api/admin/plugins/[id]/config - Read plugin config * GET /api/admin/plugins/[id]/config - Read plugin config
* *
@@ -25,7 +34,7 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
} }
const adminAuth = await requireAdminAuth(); const adminAuth = await requireAdminAuth(request);
const isAdmin = !('error' in adminAuth); const isAdmin = !('error' in adminAuth);
if (!isAdmin) { if (!isAdmin) {
@@ -35,22 +44,27 @@ export async function GET(
} }
} }
const plugin = await getPlugin(id); const plugin = await resolvePlugin(id);
if (!plugin) { if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
const config = await getPluginConfig(id); const config = await getPluginConfig(id);
let response: Record<string, unknown> = config; let response: Record<string, unknown>;
if (!isAdmin && plugin.configSchema) { if (isAdmin) {
response = config;
} else {
response = {}; response = {};
const schema = plugin.configSchema;
if (schema) {
for (const [key, value] of Object.entries(config)) { for (const [key, value] of Object.entries(config)) {
const field = plugin.configSchema[key]; const field = schema[key];
if (field?.type === 'secret') continue; if (!field || field.type === 'secret') continue;
response[key] = value; response[key] = value;
} }
} }
}
return NextResponse.json(response, { return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
@@ -71,7 +85,7 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { id } = await params; const { id } = await params;
@@ -80,7 +94,7 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
} }
const plugin = await getPlugin(id); const plugin = await resolvePlugin(id);
if (!plugin) { if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
@@ -101,6 +115,13 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 }); return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
} }
if (plugin.configSchema && !plugin.configSchema[body.key]) {
return NextResponse.json(
{ error: 'Key is not declared in the plugin configSchema' },
{ status: 400 },
);
}
await setPluginConfig(id, body.key, body.value); await setPluginConfig(id, body.key, body.value);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch { } catch {
@@ -118,7 +139,7 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const { id } = await params; const { id } = await params;
+44 -9
View File
@@ -8,8 +8,11 @@ import {
deletePlugin as removePlugin, deletePlugin as removePlugin,
type ServerPlugin, type ServerPlugin,
} from '@/lib/admin/plugin-registry'; } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { import {
sanitizeFrameOrigins, sanitizeFrameOrigins,
sanitizeHttpOrigins,
sanitizeApiPostPaths,
invalidateFrameOriginsCache, invalidateFrameOriginsCache,
} from '@/lib/admin/csp-frame-origins'; } from '@/lib/admin/csp-frame-origins';
@@ -29,13 +32,25 @@ const SUSPICIOUS_JS_PATTERNS = [
/** /**
* GET /api/admin/plugins - List all admin-managed plugins * GET /api/admin/plugins - List all admin-managed plugins
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const registry = await getPluginRegistry(); const [registry, devEntries] = await Promise.all([
return NextResponse.json(registry.plugins, { getPluginRegistry(),
listDevPlugins(),
]);
// 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
.filter(p => !devIds.has(p.id))
.map(p => ({ ...p, dev: false as const })),
];
return NextResponse.json(merged, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch (error) { } catch (error) {
@@ -49,7 +64,7 @@ export async function GET() {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -157,6 +172,8 @@ export async function POST(request: NextRequest) {
} }
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
const now = new Date().toISOString(); const now = new Date().toISOString();
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
@@ -172,16 +189,25 @@ export async function POST(request: NextRequest) {
...(manifest.configSchema && typeof manifest.configSchema === 'object' ...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}), : {}),
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
: {}),
...(declaredFrameOrigins.length > 0 ...(declaredFrameOrigins.length > 0
? { frameOrigins: declaredFrameOrigins } ? { frameOrigins: declaredFrameOrigins }
: {}), : {}),
...(declaredHttpOrigins.length > 0
? { httpOrigins: declaredHttpOrigins }
: {}),
...(declaredApiPostPaths.length > 0
? { apiPostPaths: declaredApiPostPaths }
: {}),
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
}; };
await savePlugin(plugin, code); await savePlugin(plugin, code);
invalidateFrameOriginsCache(); invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
return NextResponse.json({ plugin }); return NextResponse.json({ plugin });
} catch (error) { } catch (error) {
@@ -196,7 +222,7 @@ export async function POST(request: NextRequest) {
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -214,10 +240,19 @@ export async function PATCH(request: NextRequest) {
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled; if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
const updated = await updatePluginMeta(id, updates); let updated = await updatePluginMeta(id, updates);
if (!updated) { if (!updated) {
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
// client. Skip the registry write and return the live dev plugin so the
// policy save path can proceed.
const devEntries = await listDevPlugins();
const devEntry = devEntries.find(e => e.plugin.id === id);
if (!devEntry) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
updated = { ...devEntry.plugin, ...updates };
}
// Enable/disable changes the set of plugins contributing frame origins. // Enable/disable changes the set of plugins contributing frame origins.
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') { if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
@@ -238,7 +273,7 @@ export async function PATCH(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+1 -1
View File
@@ -26,7 +26,7 @@ export async function GET() {
*/ */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+3 -3
View File
@@ -19,9 +19,9 @@ import {
* Returns current consent + endpoint + next/last send + a live preview * Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain. * of exactly what the next heartbeat would contain.
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent(); const { consent, source, state } = await effectiveConsent();
@@ -61,7 +61,7 @@ export async function GET() {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const auth = await requireAdminAuth(); const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error; if ('error' in auth) return auth.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+5 -5
View File
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
/** /**
* GET /api/admin/themes - List all admin-managed themes * GET /api/admin/themes - List all admin-managed themes
*/ */
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const registry = await getThemeRegistry(); const registry = await getThemeRegistry();
@@ -36,7 +36,7 @@ export async function GET() {
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
*/ */
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
*/ */
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const result = await requireAdminAuth(); const result = await requireAdminAuth(request);
if ('error' in result) return result.error; if ('error' in result) return result.error;
const ip = getClientIP(request); const ip = getClientIP(request);
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import {
loadState,
checkOnce,
effectiveEndpoint,
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
/**
* GET /api/admin/version
* Returns the cached update status, last check times, and effective config.
*/
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const state = await loadState();
return NextResponse.json(
{
current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0',
build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown',
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
status: state.status,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('version admin GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
/**
* POST /api/admin/version
* { action: 'check-now' } - force a fresh upstream fetch.
*/
export async function POST(req: NextRequest) {
try {
const auth = await requireAdminAuth(req);
if ('error' in auth) return auth.error;
const body = (await req.json().catch(() => null)) as { action?: string } | null;
if (!body || body.action !== 'check-now') {
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
}
const result = await checkOnce({ reason: 'admin-trigger' });
return NextResponse.json(result);
} catch (err) {
logger.error('version admin POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'failed' }, { status: 500 });
}
}
+141
View File
@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import {
ImpersonationJwtError,
impersonationReplayCache,
verifyImpersonationJwt,
} from '@/lib/impersonation/jwt';
import {
readImpersonationConfig,
resolveImpersonationServerUrl,
} from '@/lib/impersonation/master-config';
export const runtime = 'nodejs';
const IMPERSONATION_SLOT = 0;
/**
* Impersonation cookies deliberately omit Max-Age so the browser treats
* them as session cookies - the impersonated session ends when the user
* closes the browser, not 30 days later. Impersonation is a temporary
* support handoff; a normal password login is the only thing that should
* survive a browser restart.
*/
function impersonationCookieOptions() {
const { maxAge: _maxAge, ...rest } = getCookieOptions();
return rest;
}
/**
* GET /api/auth/impersonate?token=<jwt>
*
* Master-user impersonation via signed JWT. The token carries the target
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
* master credentials from env, then mints the same session cookies the
* password-login path produces. The browser is redirected to "/" and the
* SPA hydrates as if the user had just logged in with master@target%master.
*
* Returns 404 when the feature is not configured so an unconfigured
* deployment does not advertise the endpoint.
*/
export async function GET(request: NextRequest) {
const config = readImpersonationConfig();
if (!config) {
// Not configured - behave exactly like an unknown route.
return new NextResponse('Not found', { status: 404 });
}
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
let claims;
try {
claims = verifyImpersonationJwt(token, config.jwtSecret, {
expectedIssuer: config.expectedIssuer,
});
} catch (err) {
if (err instanceof ImpersonationJwtError) {
logger.warn('Impersonation JWT rejected', { code: err.code });
return NextResponse.json({ error: err.message }, { status: err.status });
}
logger.error('Impersonation JWT error', {
error: err instanceof Error ? err.message : 'Unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
if (!impersonationReplayCache.consume(claims.jti, claims.exp)) {
logger.warn('Impersonation JWT replay rejected', { jti: claims.jti });
return NextResponse.json({ error: 'Token already used' }, { status: 401 });
}
const serverUrl = await resolveImpersonationServerUrl();
if (!serverUrl) {
logger.error('Impersonation requested but jmapServerUrl is not configured');
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
let normalizedServerUrl: string;
try {
normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
} catch {
return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 });
}
// Stalwart master-user impersonation: username = "<target>%<master>",
// password = <master_password>. Per Stalwart docs:
// https://stalw.art/docs/auth/authorization/administrator/
const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`;
const authHeader = `Basic ${Buffer.from(
`${impersonatedUsername}:${config.masterPassword}`,
).toString('base64')}`;
const cookieStore = await cookies();
const sessionToken = encryptSession(
normalizedServerUrl,
impersonatedUsername,
config.masterPassword,
);
cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions());
setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, {
serverUrl: normalizedServerUrl,
username: impersonatedUsername,
authHeader,
});
// Structured audit log - operators rely on this for security review.
logger.info('Impersonation session granted', {
event: 'impersonation_granted',
jti: claims.jti,
mailbox: claims.mailbox,
tenant_id: claims.tenant_id,
actor_user_id: claims.actor_user_id,
iss: claims.iss,
ip:
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
null,
referer: request.headers.get('referer'),
user_agent: request.headers.get('user-agent'),
});
void recordLogin(impersonatedUsername, normalizedServerUrl);
// Use a relative Location header so the browser resolves it against the
// public request URL. NextResponse.redirect(new URL('/', request.url))
// would absolutise to the container's internal bind (http://0.0.0.0:3000)
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
return new NextResponse(null, {
status: 303,
headers: { Location: '/' },
});
}
+32 -14
View File
@@ -4,7 +4,12 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import {
JmapAuthVerificationError,
normalizeJmapServerUrl,
validateProxyAuthHeader,
verifyJmapAuth,
} from '@/lib/auth/verify-jmap-auth';
import { import {
clearStalwartAuthContextInStore, clearStalwartAuthContextInStore,
setStalwartAuthContextInStore, setStalwartAuthContextInStore,
@@ -12,17 +17,21 @@ import {
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
const COOKIE_OPTIONS = { function sessionCookieOptions() {
return {
...getCookieOptions(), ...getCookieOptions(),
maxAge: SESSION_COOKIE_MAX_AGE, maxAge: SESSION_COOKIE_MAX_AGE,
}; };
}
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
@@ -39,10 +48,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Pin the upstream URL to the configured JMAP server so an unauthenticated // Pin the upstream URL to a configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL // caller cannot point this route at internal hosts. We accept the global
// is configured AND the deployment explicitly allows custom JMAP endpoints // `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
// do we honor the body URL — and even then it must be a public URL. // we fall back to the request URL only if `allowCustomJmapEndpoint` is on
// - and even then the URL must resolve to a public address.
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const configuredServerUrl = const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') || configManager.get<string>('jmapServerUrl', '') ||
@@ -50,11 +60,13 @@ export async function POST(request: NextRequest) {
process.env.NEXT_PUBLIC_JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
''; '';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false); const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
let upstreamUrl: string; let upstreamUrl: string;
let upstreamTrusted: boolean; let upstreamTrusted: boolean;
if (configuredServerUrl) { if (trustedUrl) {
upstreamUrl = configuredServerUrl; upstreamUrl = trustedUrl;
upstreamTrusted = true; upstreamTrusted = true;
} else if (allowCustomEndpoint) { } else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) { if (!(await isPublicHttpUrl(serverUrl))) {
@@ -66,13 +78,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot); const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); // Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie
// we write here is only ever consumed for requests on behalf of this same
// user, so bogus credentials would just yield 401s downstream rather than
// privilege escalation. Untrusted custom endpoints still verify upstream.
const normalizedServerUrl = upstreamTrusted
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
const token = encryptSession(normalizedServerUrl, username, password); const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS); cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, { setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
username, username,
@@ -181,8 +199,8 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Delete all session cookies (slots 0-4) // Delete all session cookies across every slot.
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
cookieStore.delete(sessionCookieName(i)); cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i); clearStalwartAuthContextInStore(cookieStore, i);
} }
+52 -6
View File
@@ -2,8 +2,12 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { decryptPayload } from '@/lib/auth/crypto'; import { decryptPayload } from '@/lib/auth/crypto';
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange'; import {
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; exchangeCodeForTokens,
getRequiredConfig,
getTokenEndpoint,
} from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
@@ -13,12 +17,18 @@ export async function POST(request: NextRequest) {
const cookieStore = await cookies(); const cookieStore = await cookies();
try { try {
const { code, state } = await request.json(); const { code, state, slot: bodySlot } = await request.json();
if (!code || !state) { if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 }); return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
} }
// Per-account refresh-token cookie slot. Without this the route hardcoded
// slot 0, so the "+ Add Account" flow overwrote the first account's
// refresh-token cookie. Default to 0 for back-compat with any caller that
// omits slot. Mirrors the validation in /api/auth/token POST.
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Read and decrypt the pending SSO cookie // Read and decrypt the pending SSO cookie
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
if (!pendingCookie) { if (!pendingCookie) {
@@ -49,6 +59,11 @@ export async function POST(request: NextRequest) {
const codeVerifier = pending.code_verifier as string; const codeVerifier = pending.code_verifier as string;
const redirectUri = pending.redirect_uri as string; const redirectUri = pending.redirect_uri as string;
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
const mobileRedirectUri =
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
const isMobileFlow = Boolean(mobileRedirectUri);
if (!codeVerifier || !redirectUri) { if (!codeVerifier || !redirectUri) {
cookieStore.delete(SSO_PENDING_COOKIE); cookieStore.delete(SSO_PENDING_COOKIE);
@@ -56,17 +71,48 @@ export async function POST(request: NextRequest) {
} }
// Exchange code for tokens // Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri); const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
// Store refresh token // For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
// origin (the mobile browser tab disposes of the session after the
// redirect anyway, but the cookie would still get committed to the
// user's main webmail session if they happened to be logged in there).
if (!isMobileFlow) {
if (tokens.refresh_token) { if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(0); const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
} }
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
} else {
cookieStore.delete(serverCookieName);
}
}
// Delete pending cookie // Delete pending cookie
cookieStore.delete(SSO_PENDING_COOKIE); cookieStore.delete(SSO_PENDING_COOKIE);
if (isMobileFlow) {
// The mobile client needs the bits it can't re-derive: the refresh
// token, the token endpoint it should hit to refresh later, and the
// client_id the IdP expects on that refresh call. The server URL is
// returned so the app knows which JMAP host to connect to.
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
token_endpoint: tokenEndpoint,
client_id: clientId,
server_url: serverUrl,
mobile_redirect_uri: mobileRedirectUri,
mobile_state: mobileState,
});
}
return NextResponse.json({ return NextResponse.json({
access_token: tokens.access_token, access_token: tokens.access_token,
expires_in: tokens.expires_in, expires_in: tokens.expires_in,
+40 -8
View File
@@ -5,25 +5,50 @@ import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server'; import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
import { getRequiredConfig } from '@/lib/oauth/token-exchange'; import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { hasSessionSecret } from '@/lib/auth/session-secret';
const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes 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
// caller-controlled URL the attacker chose.
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) { if (!hasSessionSecret()) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 }); return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
} }
const { redirect_uri, locale } = await request.json(); const {
redirect_uri,
locale,
server_id: bodyServerId,
mobile_redirect_uri: rawMobileRedirectUri,
mobile_state: rawMobileState,
} = await request.json();
if (!redirect_uri || typeof redirect_uri !== 'string') { if (!redirect_uri || typeof redirect_uri !== 'string') {
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 }); return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
} }
const mobileRedirectUri =
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
? rawMobileRedirectUri
: null;
const mobileState =
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
}
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Validate redirect_uri origin matches the request origin to prevent open redirects // Validate redirect_uri origin matches the request origin to prevent open redirects
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin; const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
try { try {
@@ -36,8 +61,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 }); return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
} }
const { clientId, discoveryUrl } = getRequiredConfig(); const { clientId, discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl); const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
if (!metadata?.authorization_endpoint) { if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
@@ -48,12 +73,19 @@ export async function POST(request: NextRequest) {
const codeChallenge = generateCodeChallengeServer(codeVerifier); const codeChallenge = generateCodeChallengeServer(codeVerifier);
const state = generateStateServer(); const state = generateStateServer();
// Encrypt and store in httpOnly cookie // Encrypt and store in httpOnly cookie. server_id is captured here so the
// /complete handler reaches the same OAuth endpoint we used to authorize.
// Mobile params are captured here so /complete knows to return tokens to
// the caller (in the JSON response) instead of writing the usual server
// cookies - and so the callback page can redirect back to the app.
const pendingData = { const pendingData = {
state, state,
code_verifier: codeVerifier, code_verifier: codeVerifier,
redirect_uri, redirect_uri,
created_at: Date.now(), created_at: Date.now(),
...(serverId ? { server_id: serverId } : {}),
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
...(mobileState ? { mobile_state: mobileState } : {}),
}; };
const encrypted = encryptPayload(pendingData); const encrypted = encryptPayload(pendingData);
@@ -69,7 +101,7 @@ export async function POST(request: NextRequest) {
authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirect_uri); authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('scope', OAUTH_SCOPES); authUrl.searchParams.set('scope', getOauthScopes());
authUrl.searchParams.set('state', state); authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('code_challenge_method', 'S256');
+28 -10
View File
@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest, bodySlot: unknown): number { function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
return bodySlot; return bodySlot;
} }
@@ -15,7 +17,7 @@ function getSlot(request: NextRequest, bodySlot: unknown): number {
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot; return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? 0 : slot;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -26,10 +28,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
// Pin the upstream URL to the configured JMAP server so an unauthenticated // Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
// caller cannot point this route at internal hosts. Only when no server URL // or any entry in `jmapServers`). Falls back to the request URL only when
// is configured AND the deployment explicitly allows custom JMAP endpoints // `allowCustomJmapEndpoint` is enabled, and even then it must be public.
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const configuredServerUrl = const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') || configManager.get<string>('jmapServerUrl', '') ||
@@ -37,11 +38,13 @@ export async function POST(request: NextRequest) {
process.env.NEXT_PUBLIC_JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
''; '';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false); const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
let upstreamUrl: string; let upstreamUrl: string;
let upstreamTrusted: boolean; let upstreamTrusted: boolean;
if (configuredServerUrl) { if (trustedUrl) {
upstreamUrl = configuredServerUrl; upstreamUrl = trustedUrl;
upstreamTrusted = true; upstreamTrusted = true;
} else if (allowCustomEndpoint) { } else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) { if (!(await isPublicHttpUrl(serverUrl))) {
@@ -54,7 +57,22 @@ export async function POST(request: NextRequest) {
} }
const slot = getSlot(request, bodySlot); const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); // Trusted (admin-configured) URLs skip the upstream re-fetch, but we
// still bind the cookie's `username` to the credential when we can verify
// locally. Without this, a caller can POST username="admin@host" +
// authHeader=<their own Basic creds>, and downstream consumers that read
// the cookie-derived username (audit logs, login tracker) accept the
// spoof. Bearer tokens are opaque so only the format check runs;
// authorization sinks must key off the credential itself, not the
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
let normalizedServerUrl: string;
if (upstreamTrusted) {
validateProxyAuthHeader(authHeader);
assertBasicAuthMatchesUsername(authHeader, username);
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
} else {
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
}
await setStalwartAuthContext(slot, { await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl, serverUrl: normalizedServerUrl,
+32 -14
View File
@@ -1,40 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange'; import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function getSlot(request: NextRequest): number { function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot'); const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0; if (raw === null) return 0;
const slot = parseInt(raw, 10); const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0; if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0;
return slot; return slot;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json(); const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
if (!code || !code_verifier || !redirect_uri) { if (!code || !code_verifier || !redirect_uri) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request); const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request);
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri); const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
const response = NextResponse.json({ const response = NextResponse.json({
access_token: tokens.access_token, access_token: tokens.access_token,
expires_in: tokens.expires_in, expires_in: tokens.expires_in,
}); });
const cookieStore = await cookies();
if (tokens.refresh_token) { if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot); const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
} }
// Persist which server entry minted this refresh token so the PUT/DELETE
// handlers can route the refresh/revocation calls to the right token
// endpoint without the client having to track it across page loads.
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
cookieStore.set(serverCookieName, serverId, getCookieOptions());
} else {
cookieStore.delete(serverCookieName);
}
return response; return response;
} catch (error) { } catch (error) {
@@ -49,17 +60,18 @@ export async function PUT(request: NextRequest) {
const cookieName = refreshTokenCookieName(slot); const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies(); const cookieStore = await cookies();
const refreshToken = cookieStore.get(cookieName)?.value; const refreshToken = cookieStore.get(cookieName)?.value;
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
if (!refreshToken) { if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 }); return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
} }
const tokenEndpoint = await getTokenEndpoint(); const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({ const params = buildOAuthParams({
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
}); }, serverId);
const tokenResponse = await fetch(tokenEndpoint, { const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST', method: 'POST',
@@ -71,6 +83,7 @@ export async function PUT(request: NextRequest) {
const errorText = await tokenResponse.text(); const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText }); logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
cookieStore.delete(cookieName); cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
} }
@@ -100,17 +113,19 @@ export async function DELETE(request: NextRequest) {
const all = request.nextUrl.searchParams.get('all') === 'true'; const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) { if (all) {
// Revoke and delete all refresh token cookies (slots 0-4) // Revoke and delete all refresh token cookies across every slot.
const cookieStore = await cookies(); const cookieStore = await cookies();
for (let i = 0; i <= 4; i++) { for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) {
const name = refreshTokenCookieName(i); const name = refreshTokenCookieName(i);
const serverCookieName = refreshTokenServerCookieName(i);
const token = cookieStore.get(name)?.value; const token = cookieStore.get(name)?.value;
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
if (token) { if (token) {
// Best-effort revocation // Best-effort revocation
try { try {
const metadata = await getMetadata().catch(() => null); const metadata = await getMetadata(slotServerId).catch(() => null);
if (metadata?.revocation_endpoint) { if (metadata?.revocation_endpoint) {
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }); const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId);
await fetch(metadata.revocation_endpoint, { await fetch(metadata.revocation_endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -120,6 +135,7 @@ export async function DELETE(request: NextRequest) {
} catch { /* best effort */ } } catch { /* best effort */ }
cookieStore.delete(name); cookieStore.delete(name);
} }
cookieStore.delete(serverCookieName);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -128,7 +144,8 @@ export async function DELETE(request: NextRequest) {
const cookieName = refreshTokenCookieName(slot); const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies(); const cookieStore = await cookies();
const refreshToken = cookieStore.get(cookieName)?.value; const refreshToken = cookieStore.get(cookieName)?.value;
const metadata = await getMetadata().catch((err) => { const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
const metadata = await getMetadata(slotServerId).catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', { logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error', error: err instanceof Error ? err.message : 'Unknown error',
}); });
@@ -140,7 +157,7 @@ export async function DELETE(request: NextRequest) {
const params = buildOAuthParams({ const params = buildOAuthParams({
token: refreshToken, token: refreshToken,
token_type_hint: 'refresh_token', token_type_hint: 'refresh_token',
}); }, slotServerId);
try { try {
const revocationResponse = await fetch(metadata.revocation_endpoint, { const revocationResponse = await fetch(metadata.revocation_endpoint, {
@@ -158,6 +175,7 @@ export async function DELETE(request: NextRequest) {
cookieStore.delete(cookieName); cookieStore.delete(cookieName);
} }
cookieStore.delete(refreshTokenServerCookieName(slot));
let end_session_url: string | undefined; let end_session_url: string | undefined;
if (metadata?.end_session_endpoint) { if (metadata?.end_session_endpoint) {
+45 -17
View File
@@ -2,12 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery'; import { discoverOAuth } from '@/lib/oauth/discovery';
import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker'; import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -52,7 +54,7 @@ async function tryTokenRequest(
async function findTokenEndpoint(serverUrl: string): Promise<string | null> { async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
// 1. Try OAuth discovery // 1. Try OAuth discovery
const metadata = await discoverOAuth(serverUrl); const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
if (metadata?.token_endpoint) return metadata.token_endpoint; if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly // 2. Try common Stalwart token endpoint paths directly
@@ -78,18 +80,19 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { serverUrl, username, password, slot: bodySlot } = await request.json(); const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
if (!serverUrl || !username || !password) { if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
} }
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
// Pin the upstream URL to the configured JMAP server so an unauthenticated // Pin the upstream URL to a configured JMAP server. The list of allowed
// caller cannot point this route at internal hosts. Only when no server // servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
// URL is configured (and the deployment explicitly allows custom JMAP // no server is configured (and the deployment explicitly allows custom
// endpoints) do we fall back to the user-supplied URL - and even then // JMAP endpoints) do we fall back to the user-supplied URL - and even then
// it must resolve to a public address. // it must resolve to a public address.
await configManager.ensureLoaded(); await configManager.ensureLoaded();
const configuredServerUrl = const configuredServerUrl =
@@ -98,9 +101,17 @@ export async function POST(request: NextRequest) {
process.env.NEXT_PUBLIC_JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
''; '';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false); const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
let upstreamUrl: string; let upstreamUrl: string;
if (configuredServerUrl) { let resolvedServerId: string | null = null;
const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) {
upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id;
} else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl; upstreamUrl = configuredServerUrl;
} else if (allowCustomEndpoint) { } else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) { if (!(await isPublicHttpUrl(serverUrl))) {
@@ -118,7 +129,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
} }
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot); return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
} catch (error) { } catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -131,11 +142,21 @@ async function attemptAllStrategies(
username: string, username: string,
password: string, password: string,
slot: number, slot: number,
serverId: string | null,
): Promise<NextResponse> { ): Promise<NextResponse> {
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; // Per-server OAuth credentials override the global ones when the requested
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); // server entry has its own oauth block configured.
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const entry = findServerById(serverList, serverId);
const clientId = entry?.oauth?.clientId
|| configManager.get<string>('oauthClientId', '')
|| process.env.OAUTH_CLIENT_ID;
const clientSecret = entry?.oauth?.clientSecret
|| configManager.get<string>('oauthClientSecret', '')
|| process.env.OAUTH_CLIENT_SECRET
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const attempts: Array<{ strategy: string; error: string }> = []; const attempts: Array<{ strategy: string; error: string }> = [];
@@ -147,7 +168,7 @@ async function attemptAllStrategies(
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)'); logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl); void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot, serverId);
} }
attempts.push({ strategy: 'ROPC with client_id', error: result.error }); attempts.push({ strategy: 'ROPC with client_id', error: result.error });
} }
@@ -159,7 +180,7 @@ async function attemptAllStrategies(
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)'); logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl); void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot, serverId);
} }
attempts.push({ strategy: 'ROPC without client_id', error: result.error }); attempts.push({ strategy: 'ROPC without client_id', error: result.error });
} }
@@ -171,7 +192,7 @@ async function attemptAllStrategies(
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)'); logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl); void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot, serverId);
} }
attempts.push({ strategy: 'Basic Auth header', error: result.error }); attempts.push({ strategy: 'Basic Auth header', error: result.error });
} }
@@ -183,7 +204,7 @@ async function attemptAllStrategies(
if (result.ok) { if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)'); logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl); void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot); return await storeAndRespond(result.tokens, slot, serverId);
} }
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error }); attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
} }
@@ -199,12 +220,19 @@ async function attemptAllStrategies(
async function storeAndRespond( async function storeAndRespond(
tokens: { access_token: string; expires_in?: number; refresh_token?: string }, tokens: { access_token: string; expires_in?: number; refresh_token?: string },
slot: number, slot: number,
serverId: string | null,
): Promise<NextResponse> { ): Promise<NextResponse> {
const cookieStore = await cookies();
if (tokens.refresh_token) { if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot); const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
} }
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
cookieStore.set(serverCookieName, serverId, getCookieOptions());
} else {
cookieStore.delete(serverCookieName);
}
return NextResponse.json({ return NextResponse.json({
access_token: tokens.access_token, access_token: tokens.access_token,
+8 -3
View File
@@ -1,7 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
/** /**
* Runtime configuration endpoint * Runtime configuration endpoint
@@ -34,8 +36,9 @@ export async function GET() {
oauthOnly, oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''), oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''), oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE), oauthScopes: getOauthScopes(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)), rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled, stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false), devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'), faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
@@ -49,6 +52,8 @@ export async function GET() {
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''), loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false), demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false), allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false), autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''), parentOrigin: configManager.get<string>('parentOrigin', ''),
+47 -21
View File
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
// ===================================================================== // =====================================================================
{ {
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0), id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Willkommen bei Bulwark Webmail!', subject: 'Willkommen bei Bulwark Webmail!',
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.', preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
hasAttachment: false, hasAttachment: false,
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
htmlBody: [], htmlBody: [],
bodyValues: { bodyValues: {
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' }, p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
}, },
}, },
{ {
@@ -197,7 +197,7 @@ const emails: MockEmail[] = [
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2), id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], to: [{ name: 'Dev User', email: 'dev@localhost' }],
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Sprint planning - next week priorities', subject: 'Sprint planning - next week priorities',
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.', preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
hasAttachment: false, hasAttachment: false,
@@ -367,7 +367,7 @@ const emails: MockEmail[] = [
}, },
{ {
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1), id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities', subject: 'Re: Sprint planning - next week priorities',
@@ -471,7 +471,7 @@ const emails: MockEmail[] = [
{ {
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5), id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
from: [{ name: 'Dev User', email: 'dev@localhost' }], from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [], to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
subject: 'Design review feedback', subject: 'Design review feedback',
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.', preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
hasAttachment: false, hasAttachment: false,
@@ -485,7 +485,7 @@ const emails: MockEmail[] = [
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5), id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
from: [{ name: 'Dev User', email: 'dev@localhost' }], from: [{ name: 'Dev User', email: 'dev@localhost' }],
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }], to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
subject: 'Re: Sprint planning - next week priorities', subject: 'Re: Sprint planning - next week priorities',
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.', preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
hasAttachment: false, hasAttachment: false,
@@ -639,7 +639,7 @@ const emails: MockEmail[] = [
}, },
{ {
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30), id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
subject: 'Conference talk accepted!', subject: 'Conference talk accepted!',
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!', preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
@@ -728,8 +728,8 @@ const IDENTITIES = [
email: 'dev@localhost', email: 'dev@localhost',
replyTo: null, replyTo: null,
bcc: null, bcc: null,
textSignature: '-- \nDev User\nBulwark Webmail Developer', textSignature: 'Dev User\nBulwark Webmail Developer',
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>', htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
mayDelete: false, mayDelete: false,
}, },
]; ];
@@ -743,6 +743,12 @@ const addressBooks = [
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false }, { id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
]; ];
// Profile photos served straight from randomuser.me's CDN; the API at
// https://randomuser.me/api/ also returns these portrait URLs, but for a
// fixed mock dataset we link them directly to keep things offline-friendly.
// See https://randomuser.me/documentation#howto
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
const contacts = [ const contacts = [
// --- Personal address book --- // --- Personal address book ---
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
@@ -752,6 +758,7 @@ const contacts = [
organizations: { o1: { name: 'EuroTech GmbH' } }, organizations: { o1: { name: 'EuroTech GmbH' } },
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } }, addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } }, notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] }, name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
@@ -760,6 +767,7 @@ const contacts = [
organizations: { o1: { name: 'Dubois Consulting' } }, organizations: { o1: { name: 'Dubois Consulting' } },
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } }, addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } }, notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] }, name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
@@ -768,6 +776,7 @@ const contacts = [
organizations: { o1: { name: 'Rossi Design Studio' } }, organizations: { o1: { name: 'Rossi Design Studio' } },
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } }, addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } }, notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] }, name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
@@ -775,6 +784,7 @@ const contacts = [
phones: { p1: { number: '+31 20 555 0142' } }, phones: { p1: { number: '+31 20 555 0142' } },
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } }, addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } }, notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] }, name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
@@ -783,6 +793,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } }, organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } }, addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } }, notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] }, name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
@@ -791,6 +802,7 @@ const contacts = [
organizations: { o1: { name: 'Fjord Systems AB' } }, organizations: { o1: { name: 'Fjord Systems AB' } },
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } }, addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } }, notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] }, name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
@@ -798,6 +810,7 @@ const contacts = [
phones: { p1: { number: '+39 06 9876 5432' } }, phones: { p1: { number: '+39 06 9876 5432' } },
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } }, addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } }, notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] }, name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
@@ -806,6 +819,7 @@ const contacts = [
organizations: { o1: { name: 'BergLabs' } }, organizations: { o1: { name: 'BergLabs' } },
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } }, addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } }, notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] }, name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
@@ -814,6 +828,7 @@ const contacts = [
organizations: { o1: { name: 'Nielsen Konsult' } }, organizations: { o1: { name: 'Nielsen Konsult' } },
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } }, addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } }, notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual', { id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] }, name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
@@ -822,6 +837,7 @@ const contacts = [
organizations: { o1: { name: 'Sorbonne Université' } }, organizations: { o1: { name: 'Sorbonne Université' } },
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } }, addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } }, notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
}, },
// --- Work address book --- // --- Work address book ---
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
@@ -831,6 +847,7 @@ const contacts = [
organizations: { o1: { name: 'Lefèvre & Associés' } }, organizations: { o1: { name: 'Lefèvre & Associés' } },
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } }, addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } }, notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] }, name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
@@ -839,6 +856,7 @@ const contacts = [
organizations: { o1: { name: 'Charité Klinik Berlin' } }, organizations: { o1: { name: 'Charité Klinik Berlin' } },
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } }, addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } }, notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] }, name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
@@ -847,6 +865,7 @@ const contacts = [
organizations: { o1: { name: 'Finanz Dublin' } }, organizations: { o1: { name: 'Finanz Dublin' } },
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } }, addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } }, notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] }, name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
@@ -855,6 +874,7 @@ const contacts = [
organizations: { o1: { name: 'García Design Studio' } }, organizations: { o1: { name: 'García Design Studio' } },
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } }, addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } }, notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] }, name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
@@ -863,6 +883,7 @@ const contacts = [
organizations: { o1: { name: 'Digitaal BV' } }, organizations: { o1: { name: 'Digitaal BV' } },
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } }, addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } }, notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] }, name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
@@ -871,6 +892,7 @@ const contacts = [
organizations: { o1: { name: 'Kowalska Marketing' } }, organizations: { o1: { name: 'Kowalska Marketing' } },
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } }, addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } }, notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] }, name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
@@ -879,6 +901,7 @@ const contacts = [
organizations: { o1: { name: 'Murphy Bau GmbH' } }, organizations: { o1: { name: 'Murphy Bau GmbH' } },
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } }, addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } }, notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] }, name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
@@ -887,6 +910,7 @@ const contacts = [
organizations: { o1: { name: 'Ferreira Media' } }, organizations: { o1: { name: 'Ferreira Media' } },
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } }, addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } }, notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] }, name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
@@ -895,6 +919,7 @@ const contacts = [
organizations: { o1: { name: 'Dumont Conseil' } }, organizations: { o1: { name: 'Dumont Conseil' } },
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } }, addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } }, notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
}, },
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual', { id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] }, name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
@@ -904,6 +929,7 @@ const contacts = [
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } }, addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
nicknames: { n1: { name: 'Anni' } }, nicknames: { n1: { name: 'Anni' } },
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } }, notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
}, },
// --- Groups --- // --- Groups ---
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const, { id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
@@ -976,7 +1002,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
}, },
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } }, alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
@@ -986,7 +1012,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
}, },
@@ -1024,7 +1050,7 @@ const calendarEvents = [
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } }, virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Sophie Müller', 'sophie@eurotech.example'), p2: participant('Sophie Example', 'sophie@eurotech.example'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
}, },
description: 'Discuss API rate limit escalation for EuroTech enterprise account.', description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
@@ -1054,7 +1080,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'), p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
p6: participant('Pierre Dubois', 'pierre@dubois.example'), p6: participant('Pierre Dubois', 'pierre@dubois.example'),
@@ -1066,7 +1092,7 @@ const calendarEvents = [
participants: { participants: {
p1: participant('Dev User', 'dev@localhost'), p1: participant('Dev User', 'dev@localhost'),
p2: participant('María García', 'maria@garcia-design.example', 'owner'), p2: participant('María García', 'maria@garcia-design.example', 'owner'),
p3: participant('Sophie Müller', 'sophie@eurotech.example'), p3: participant('Sophie Example', 'sophie@eurotech.example'),
}, },
}), }),
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', { makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
@@ -1084,7 +1110,7 @@ const calendarEvents = [
p2: participant('Dev User', 'dev@localhost'), p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'),
p5: participant('Sophie Müller', 'sophie@eurotech.example'), p5: participant('Sophie Example', 'sophie@eurotech.example'),
}, },
}), }),
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', { makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
@@ -1093,7 +1119,7 @@ const calendarEvents = [
p1: participant('Dev User', 'dev@localhost', 'owner'), p1: participant('Dev User', 'dev@localhost', 'owner'),
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'), p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'), p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
p4: participant('Sophie Müller', 'sophie@eurotech.example'), p4: participant('Sophie Example', 'sophie@eurotech.example'),
}, },
}), }),
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', { makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
@@ -1109,7 +1135,7 @@ const calendarEvents = [
location: 'Sophie\'s apartment, Kreuzberg, Berlin', location: 'Sophie\'s apartment, Kreuzberg, Berlin',
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.', description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
participants: { participants: {
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'), p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
p2: participant('Dev User', 'dev@localhost'), p2: participant('Dev User', 'dev@localhost'),
p3: participant('Pierre Dubois', 'pierre@dubois.example'), p3: participant('Pierre Dubois', 'pierre@dubois.example'),
p4: participant('Chiara Rossi', 'chiara@rossi.example'), p4: participant('Chiara Rossi', 'chiara@rossi.example'),
@@ -1192,7 +1218,7 @@ const calendarEvents = [
}), }),
// ===== Birthday calendar (cal-5) ===== // ===== Birthday calendar (cal-5) =====
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', { makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
showWithoutTime: true, showWithoutTime: true,
recurrence: [{ frequency: 'yearly' }], recurrence: [{ frequency: 'yearly' }],
description: 'Don\'t forget to bring Kuchen!', description: 'Don\'t forget to bring Kuchen!',
@@ -1220,7 +1246,7 @@ const calendarEvents = [
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!', description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
participants: { participants: {
p1: participant('Dev User', 'dev@localhost'), p1: participant('Dev User', 'dev@localhost'),
p2: participant('Sophie Müller', 'sophie@eurotech.example'), p2: participant('Sophie Example', 'sophie@eurotech.example'),
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'), p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
}, },
}), }),
+39 -6
View File
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000; const FETCH_TIMEOUT_MS = 15000;
function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: string | null } | null {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}
let authHeader: string | null = null;
if (parsed.username || parsed.password) {
const username = decodeURIComponent(parsed.username);
const password = decodeURIComponent(parsed.password);
authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
parsed.username = '';
parsed.password = '';
}
return { cleanUrl: parsed.toString(), authHeader };
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let body: { url?: string }; let body: { url?: string };
try { try {
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 }); return NextResponse.json({ error: 'URL is required' }, { status: 400 });
} }
if (!(await isPublicHttpUrl(url))) { const extracted = extractBasicAuth(url);
if (!extracted) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
const { cleanUrl, authHeader } = extracted;
if (!(await isPublicHttpUrl(cleanUrl))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
} }
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
let currentUrl = url; let currentUrl = cleanUrl;
const originalOrigin = new URL(cleanUrl).origin;
let response: Response | undefined; let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) { for (let i = 0; i <= MAX_REDIRECTS; i++) {
@@ -36,12 +64,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 }); return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
} }
response = await fetch(currentUrl, { const headers: Record<string, string> = {
signal: controller.signal,
headers: {
'Accept': 'text/calendar, application/ics, text/plain, */*', 'Accept': 'text/calendar, application/ics, text/plain, */*',
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher', 'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
}, };
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
headers['Authorization'] = authHeader;
}
response = await fetch(currentUrl, {
signal: controller.signal,
headers,
redirect: 'manual', redirect: 'manual',
}); });
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { logger } from '@/lib/logger';
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
/**
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
*
* Any logged-in user may query the server-side approval state for a plugin
* they want to enable. The client uses this BEFORE running `enablePlugin`
* when the `requirePluginApproval` policy is set.
*
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
* pending approval entry (or returns the existing one).
*/
async function resolveUsername(): Promise<string | null> {
const cookieStore = await cookies();
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const sess = decryptSession(token);
if (sess?.username) return sess.username;
}
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx?.username) return ctx.username;
}
return null;
}
function isValidId(s: unknown): s is string {
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
}
function isValidHash(s: unknown): s is string {
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
}
export async function GET(request: NextRequest) {
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
const pluginId = request.nextUrl.searchParams.get('pluginId');
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
const status = await getApprovalStatus(pluginId, bundleHash);
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
} catch (err) {
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
let body: unknown;
try { body = await request.json(); } catch { body = null; }
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
}
const m = (b.manifest ?? {}) as Record<string, unknown>;
const manifest: ApprovalEntry['manifest'] = {
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
};
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
} catch (err) {
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
import { logger } from '@/lib/logger';
/**
* GET /api/plugin-signing-pubkey
*
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
* sandboxed plugin loader can verify bundle signatures before evaluation.
* Public - every logged-in user needs to fetch it on app boot.
*
* The response is long-cache-eligible (the key rotates only when an operator
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
* client caches the result in memory for the lifetime of the page.
*/
export async function GET() {
try {
const publicKey = await getPublicKeyBase64();
return NextResponse.json(
{ algorithm: 'ed25519', publicKey },
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
}
}
+35 -7
View File
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
/** /**
@@ -10,15 +12,25 @@ import { logger } from '@/lib/logger';
*/ */
export async function GET() { export async function GET() {
try { try {
const [pluginRegistry, themeRegistry] = await Promise.all([ await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
listDevPlugins(),
]); ]);
// Only serve enabled plugins // Dev plugins win on id collision so a developer can shadow an installed
const plugins = pluginRegistry.plugins // plugin without uninstalling it first.
.filter(p => p.enabled) const devIds = new Set(devEntries.map(e => e.plugin.id));
.map(p => ({ const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
const plugins = [
...devEntries.map(e => ({ ...e.plugin, dev: true })),
...installedEnabled.map(p => ({ ...p, dev: false })),
].map(p => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
version: p.version, version: p.version,
@@ -27,8 +39,24 @@ export async function GET() {
type: p.type, type: p.type,
permissions: p.permissions, permissions: p.permissions,
entrypoint: p.entrypoint, entrypoint: p.entrypoint,
forceEnabled: p.forceEnabled || false, // Policy is the canonical source for force-enable. The per-plugin field
settingsSchema: undefined, // Will be read from the bundle's manifest // can drift for dev plugins (manifest always loads forceEnabled:false)
// and during pending policy saves; OR'ing here unifies the signal so
// the client's auto-enable path triggers consistently.
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
// Content hash + updatedAt let clients detect re-uploads even when
// the manifest version is unchanged.
bundleHash: p.bundleHash,
updatedAt: p.updatedAt,
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
dev: p.dev,
// Surface so clients can enforce api.http.fetch origin allowlists.
httpOrigins: p.httpOrigins,
// Surface so clients can enforce api.http.post path allowlists.
apiPostPaths: p.apiPostPaths,
// Per-user settings schema, captured from the manifest at upload/load
// time so the client can render the settings UI without re-parsing.
settingsSchema: p.settingsSchema,
})); }));
// Only serve enabled themes // Only serve enabled themes
+127 -21
View File
@@ -1,10 +1,73 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import {
getStalwartCredentials,
type StalwartCredentials,
} from '@/lib/stalwart/credentials';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
interface ResolvedTarget {
authHeader: string;
apiUrl: string;
accountId: string;
}
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
// that account - not just "the first signed-in slot", which is what
// getStalwartCredentials() defaults to. Probe each candidate's session in
// parallel and return the first match.
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
const cookieStore = await cookies();
const probes: Promise<ResolvedTarget | null>[] = [];
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (!ctx) continue;
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
probes.push(
(async () => {
try {
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: ctx.authHeader },
});
if (!res.ok) return null;
const session = (await res.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!session.apiUrl || !mailAccountId) return null;
if (mailAccountId !== accountId) return null;
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
} catch {
return null;
}
})(),
);
}
const results = await Promise.all(probes);
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
}
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) return null;
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) return null;
return { authHeader: creds.authHeader, apiUrl, accountId };
}
/** /**
* GET /api/push/preview * GET /api/push/preview
* *
@@ -19,31 +82,41 @@ export const dynamic = 'force-dynamic';
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
// StateChange so multi-account browsers fetch from the right slot. Older
// clients (and the manual /api/push/preview probe) omit it and fall back
// to the first signed-in slot.
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
let target: ResolvedTarget | null = null;
let authHeader: string;
if (requestedAccountId) {
target = await resolveTargetForAccount(requestedAccountId);
if (!target) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
authHeader = target.authHeader;
} else {
const creds = await getStalwartCredentials(request); const creds = await getStalwartCredentials(request);
if (!creds) { if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
} }
target = await resolveDefaultTarget(creds);
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, { if (!target) {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 }); return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
} }
const session = (await sessionRes.json()) as { authHeader = creds.authHeader;
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
} }
// Find the inbox, then pull the most recent unread message in it. We use const { apiUrl, accountId } = target;
// a single batched JMAP request with back-references so this round-trip
// is one POST regardless of how many messages exist. const inboxRes = await fetch(apiUrl, {
const requestBody = { method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [ methodCalls: [
[ [
@@ -51,6 +124,39 @@ export async function GET(request: NextRequest) {
{ accountId, filter: { role: 'inbox' }, limit: 1 }, { accountId, filter: { role: 'inbox' }, limit: 1 },
'mb', 'mb',
], ],
],
}),
});
if (!inboxRes.ok) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxData = (await inboxRes.json()) as {
methodResponses: [string, Record<string, unknown>, string][];
};
const inboxBody = inboxData.methodResponses.find(
([method]) => method === 'Mailbox/query',
)?.[1] as { ids?: string[] } | undefined;
const inboxId = inboxBody?.ids?.[0];
if (!inboxId) {
return NextResponse.json({
email: null,
unreadTotal: 0,
}, {
headers: {
'Cache-Control': 'no-store',
},
});
}
// Pull the most recent unread message from the resolved Inbox mailbox.
const requestBody = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[ [
'Email/query', 'Email/query',
{ {
@@ -58,7 +164,7 @@ export async function GET(request: NextRequest) {
filter: { filter: {
operator: 'AND', operator: 'AND',
conditions: [ conditions: [
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } }, { inMailbox: inboxId },
{ notKeyword: '$seen' }, { notKeyword: '$seen' },
], ],
}, },
@@ -83,7 +189,7 @@ export async function GET(request: NextRequest) {
const jmapRes = await fetch(apiUrl, { const jmapRes = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: creds.authHeader, Authorization: authHeader,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
@@ -127,7 +233,7 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
// `fetch failed` from undici is too generic to debug the real reason // `fetch failed` from undici is too generic to debug - the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`. // (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } }; const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', { logger.error('push preview failed', {
+6 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp'; import sharp from 'sharp';
import path from 'node:path'; import path from 'node:path';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
const VALID_SIZES = new Set([192, 512]); const VALID_SIZES = new Set([192, 512]);
@@ -32,7 +33,11 @@ export async function GET(
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 }); return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
} }
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL; await configManager.ensureLoaded();
const sources = configManager.getAllWithSources();
const iconUrl =
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) { if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 }); return new NextResponse('No PWA icon configured', { status: 404 });
} }
+8 -4
View File
@@ -6,7 +6,8 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env'; import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } { function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code; const code = (error as NodeJS.ErrnoException).code;
@@ -49,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
} }
function isEnabled(): boolean { function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)); const flagOn =
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
configManager.get<boolean>('settingsSyncEnabled', false);
return flagOn && hasSessionSecret();
} }
/** Strip trailing slashes so differently-formatted URLs still match. */ /** Strip trailing slashes so differently-formatted URLs still match. */
@@ -59,7 +63,7 @@ function normalizeUrl(url: string): string {
/** /**
* Verify identity against session cookies across all account slots. * Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot (0-4). * With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies * Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions). * (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns true only if a matching cookie is found. * Returns true only if a matching cookie is found.
@@ -68,7 +72,7 @@ async function verifyIdentity(username: string, serverUrl: string): Promise<bool
const cookieStore = await cookies(); const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl); const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot <= 4; slot++) { for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// Check basic-auth session cookie // Check basic-auth session cookie
const token = cookieStore.get(sessionCookieName(slot))?.value; const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) { if (token) {
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
const ALLOWED_MIME_TYPES = new Set([
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp',
'image/x-icon',
'image/vnd.microsoft.icon',
]);
const VALID_SLOTS = new Set([
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
]);
const EXT_BY_MIME: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
function getBrandingDir(): string {
return path.join(getConfigDir(), 'branding');
}
function sanitizeFilename(name: string): string {
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* POST /api/setup/branding - wizard branding upload.
*
* Multipart form fields:
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
*
* Mirrors /api/admin/branding but authenticates via the wizard cookie
* instead of admin session - admin auth doesn't exist yet during bootstrap.
* Files land in the same directory; the public read endpoint at
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
* assets after setup.
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
try {
assertWritable('upload branding asset');
const formData = await request.formData();
const file = formData.get('file');
const slot = formData.get('slot');
if (!(file instanceof File) || typeof slot !== 'string') {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
}
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
{ status: 400 },
);
}
const ext = EXT_BY_MIME[file.type] ?? '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const dir = getBrandingDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
// Remove any existing file for this slot with a different extension so
// the wizard doesn't leave orphan files behind on re-upload.
for (const otherExt of Object.values(EXT_BY_MIME)) {
if (otherExt === ext) continue;
const oldPath = path.join(dir, `${slot}${otherExt}`);
if (existsSync(oldPath)) {
try { await unlink(oldPath); } catch { /* ignore */ }
}
}
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dir, safeName);
await writeFile(filePath, buffer);
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
logger.error('Wizard branding upload failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
/**
* DELETE /api/setup/branding - remove an uploaded asset and clear the
* config override so the slot falls back to the system default.
*
* Body: { slot: string }
*/
export async function DELETE(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
try {
assertWritable('remove branding asset');
const { slot } = (await request.json()) as { slot?: string };
if (!slot || !VALID_SLOTS.has(slot)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
const dir = getBrandingDir();
for (const ext of Object.values(EXT_BY_MIME)) {
const filePath = path.join(dir, `${slot}${ext}`);
if (existsSync(filePath)) {
try { await unlink(filePath); } catch { /* ignore */ }
}
}
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Wizard branding delete failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'node:fs/promises';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { setInitialAdminPassword } from '@/lib/admin/password';
import { clearSetupToken } from '@/lib/setup/token';
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/finish
*
* Final wizard step. Validates that required config is in place, hashes the
* admin password, marks setup complete, deletes the setup token (which
* invalidates the wizard cookie), and optionally drops a `.config-locked`
* marker so the operator remembers they intended to mount :ro.
*
* Body: { adminPassword: string, lockConfig?: boolean }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { adminPassword?: unknown; lockConfig?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const adminPassword =
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
if (adminPassword.length < 8) {
return NextResponse.json(
{ error: 'Admin password must be at least 8 characters' },
{ status: 400 },
);
}
const lockConfig = body?.lockConfig === true;
// Validate required config is present.
await configManager.ensureLoaded();
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
if (!jmapUrl || typeof jmapUrl !== 'string') {
return NextResponse.json(
{ error: 'JMAP server URL is required (run the Server step first)' },
{ status: 400 },
);
}
try {
// 1. Provision the admin account. An admin.json file may already exist
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
// run while setupComplete is still false - accept the wizard's
// password as authoritative in that case. The finish route is gated
// by the bootstrap state + one-time setup token, so this is safe.
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
if (!created) {
return NextResponse.json(
{ error: 'Failed to write admin credentials' },
{ status: 500 },
);
}
// 2. Persist setupComplete flag. After this, detectSetupState() flips
// to 'configured' and middleware starts 404'ing /setup paths.
await configManager.markSetupComplete();
// 3. Optional advisory lock marker.
if (lockConfig) {
await ensureConfigDir();
await writeFile(
getConfigPath('.config-locked'),
new Date().toISOString(),
'utf-8',
);
}
// 4. Destroy the setup token. Any other browser holding the cookie is
// now unauthenticated.
await clearSetupToken();
await auditLog(
'setup.finish',
{ lockConfig, jmapServerUrl: jmapUrl },
request.headers.get('x-forwarded-for') ?? 'unknown',
);
const response = NextResponse.json({ ok: true, lockConfig });
response.cookies.delete(SETUP_COOKIE);
return response;
} catch (error) {
logger.error('Wizard finish failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { isConfigReadOnly } from '@/lib/admin/paths';
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
export const dynamic = 'force-dynamic';
/**
* GET /api/setup/status - public endpoint that returns the wizard state
* and (if authenticated) the partial config saved by previous steps. The
* wizard polls this on load so a refresh resumes with prior values.
*
* Sensitive values (OAuth client secret, session secret) are NEVER sent
* back to the client - only a `<key>HasValue` boolean. Re-entering them
* after refresh is the price of not exposing them.
*/
export async function GET() {
await configManager.ensureLoaded();
const state = detectSetupState();
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
let partialConfig: Record<string, unknown> | null = null;
if (state === 'bootstrap' && authenticated) {
// Only echo back values the operator has actually saved during the
// wizard (admin overrides). System defaults must not flow back here,
// because the wizard has its own opinionated defaults (e.g. settings
// sync on by default) that we'd otherwise stomp.
const sources = configManager.getAllWithSources();
const safe: Record<string, unknown> = {};
for (const [key, info] of Object.entries(sources)) {
if (info.source !== 'admin') continue;
if (SENSITIVE_CONFIG_KEYS.has(key)) {
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
} else {
safe[key] = info.value;
}
}
partialConfig = safe;
}
return NextResponse.json(
{
state,
authenticated,
readOnly: isConfigReadOnly(),
partialConfig,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* Mapping of wizard-friendly step keys to the config keys they update. Each
* step's PATCH validates against this allowlist so a compromised wizard
* client can't slip in arbitrary config keys.
*/
const STEP_KEYS: Record<string, string[]> = {
server: [
'appName',
'jmapServerUrl',
'stalwartFeaturesEnabled',
'jmapServers',
'jmapServerAutoPickByDomain',
],
auth: [
'oauthEnabled',
'oauthOnly',
'oauthClientId',
'oauthClientSecret',
'oauthIssuerUrl',
],
security: ['sessionSecret', 'settingsSyncEnabled'],
logging: ['logFormat', 'logLevel'],
branding: [
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
],
};
/**
* POST /api/setup/step
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
*
* Persists partial config under the admin override (config.json). Each
* step's allowed keys are restricted by STEP_KEYS so the client can only
* touch what the corresponding screen owns.
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { step?: unknown; values?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const step = typeof body?.step === 'string' ? body.step : '';
const values = body?.values;
const allowedKeys = STEP_KEYS[step];
if (!allowedKeys) {
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
}
if (!values || typeof values !== 'object' || Array.isArray(values)) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
if (!(key in CONFIG_ENV_MAP)) {
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
}
if (key === 'jmapServers') {
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
// before they're persisted. Mirrors the admin config PATCH route.
if (value != null && !Array.isArray(value)) {
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
}
const sanitized = parseJmapServers(value);
const incomingCount = Array.isArray(value) ? value.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json(
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
{ status: 400 },
);
}
updates[key] = sanitized;
continue;
}
updates[key] = value;
}
try {
await configManager.ensureLoaded();
await configManager.setAdminConfig(updates);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Wizard step save failed', {
step,
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
const FETCH_TIMEOUT_MS = 5000;
/**
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
* the check_jmap_server() helper in setup.sh: we hit a few common session
* endpoints and look for capability strings to confirm the URL is actually
* a JMAP server (vs. a generic HTTP 200 page).
*
* Body: { url: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { url?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
if (!raw) {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
}
const base = raw.replace(/\/+$/, '');
for (const endpoint of JMAP_ENDPOINTS) {
const target = base + endpoint;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(target, {
method: 'GET',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) continue;
const text = await res.text();
if (looksLikeJmapSession(text)) {
return NextResponse.json({
status: 'jmap_detected',
endpoint,
httpStatus: res.status,
});
}
} catch {
// Try the next endpoint; we'll fall through to a final reachability
// check below if none match.
}
}
// No JMAP session found. Was the server even reachable?
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(base, {
method: 'HEAD',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
return NextResponse.json({
status: 'reachable_no_jmap',
httpStatus: res.status,
message:
'Server responded but no JMAP session was found at standard paths. ' +
'This is OK if a reverse proxy routes JMAP separately.',
});
} catch (error) {
return NextResponse.json({
status: 'unreachable',
message: error instanceof Error ? error.message : 'Connection failed',
});
}
}
function looksLikeJmapSession(body: string): boolean {
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { verifySetupToken } from '@/lib/setup/token';
import { buildSessionCookieAttributes } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
* startup) for a wizard session cookie. After this, subsequent step calls
* authenticate via the cookie instead of pasting the token every time.
*
* Body: { token: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
let body: { token?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
if (!submitted) {
return NextResponse.json({ error: 'Token required' }, { status: 400 });
}
const ok = await verifySetupToken(submitted);
if (!ok) {
// Don't differentiate between "wrong token" and "no token issued" - the
// operator either has it from the logs or they don't.
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes(request);
response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite,
secure: attrs.secure,
path: attrs.path,
maxAge: attrs.maxAge,
});
return response;
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { checkOnce, loadState } from '@/lib/version-check';
// Public endpoint that returns the latest cached update status. Fed by the
// background scheduler started in instrumentation.node.ts; in production we
// never trigger a fresh upstream fetch from this route so an unauthenticated
// client can't use it to amplify traffic to the version server.
//
// In development we force a fresh fetch on every hit so changes to the
// version server's overrides take effect on the next page reload instead of
// requiring a dev-server restart. The 5s upstream timeout in fetchStatus
// caps the worst-case latency added to a dev reload.
export async function GET() {
if (process.env.NODE_ENV === 'development') {
await checkOnce({ reason: 'dev-reload' });
}
const state = await loadState();
return NextResponse.json(
{
status: state.status,
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
},
{
headers: {
'Cache-Control': 'no-store',
},
},
);
}

Some files were not shown because too many files have changed in this diff Show More