Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9db7b6b55f | ||
|
|
1460706e60 | ||
|
|
27d624758a | ||
|
|
5288821605 | ||
|
|
58e3ecc97a | ||
|
|
38c0694a08 | ||
|
|
71d25bb62f | ||
|
|
ef825b801a | ||
|
|
50cbd66bfd | ||
|
|
d564c874a3 | ||
|
|
568abb0e33 | ||
|
|
44781f002c | ||
|
|
941fa15251 | ||
|
|
60c7bd713e | ||
|
|
6f193e0c24 | ||
|
|
6bb85d746c | ||
|
|
bbd43b5948 | ||
|
|
180331805f | ||
|
|
9e96b1c24e | ||
|
|
40b4b26074 | ||
|
|
9863b9d88e | ||
|
|
75602b6a00 | ||
|
|
22da11514b | ||
|
|
9953557af0 | ||
|
|
8f7066d194 | ||
|
|
75c02f443f | ||
|
|
31100b8f87 | ||
|
|
152ec99262 | ||
|
|
ce401c0f59 | ||
|
|
3035fb046f | ||
|
|
4659b81538 | ||
|
|
c78dbee60b | ||
|
|
4b4c801148 | ||
|
|
2e4d3d4bc6 | ||
|
|
26ccf9e3b4 | ||
|
|
bc322a1e69 | ||
|
|
6ee3849463 | ||
|
|
abb249e5df | ||
|
|
0352312f25 | ||
|
|
ae66f8d89d | ||
|
|
55be19ede7 | ||
|
|
b508551d02 | ||
|
|
7ee329e046 | ||
|
|
1512b9afc0 | ||
|
|
ad48f4394a | ||
|
|
8d79145dba | ||
|
|
b821f8cc27 | ||
|
|
bebb394f54 | ||
|
|
2ba0003e16 | ||
|
|
4c1d0931a1 | ||
|
|
5a70cf95e0 | ||
|
|
66c5f0f52c | ||
|
|
8353b28b33 | ||
|
|
229992853b | ||
|
|
f0d87d594a | ||
|
|
196e51e91b | ||
|
|
0879030dc8 | ||
|
|
05e2837f6b | ||
|
|
241544cd08 | ||
|
|
7341e47a1b | ||
|
|
f238ca5898 | ||
|
|
00b40fc48a | ||
|
|
856496715b | ||
|
|
eb7eeae1ac | ||
|
|
6b1a99a70b | ||
|
|
1da04c254b | ||
|
|
8ae5ecba41 | ||
|
|
4ff05bd4ec | ||
|
|
31e96d6a46 | ||
|
|
82be047708 | ||
|
|
7d1fb73290 | ||
|
|
2818a16f06 | ||
|
|
e93dd44111 | ||
|
|
e86183b44a | ||
|
|
ad80aa23ca | ||
|
|
3a8daf8bff | ||
|
|
f8e7cce85a | ||
|
|
62ebe443f9 | ||
|
|
3c0faba837 | ||
|
|
956acb69ce | ||
|
|
e2abc8dee9 | ||
|
|
42ec34be21 | ||
|
|
534894c38d | ||
|
|
537707d9ed | ||
|
|
afe1e5a67c | ||
|
|
d13934c2a6 | ||
|
|
acc90eb455 | ||
|
|
5aa6d7a2f0 | ||
|
|
c46de636e0 | ||
|
|
8de8babba5 | ||
|
|
63f2169ae7 | ||
|
|
e843ef0ebb | ||
|
|
4b463f9691 | ||
|
|
58e4a3d117 | ||
|
|
e3f6ae874d | ||
|
|
52f5a5b42c | ||
|
|
e7bded82fb | ||
|
|
3ac14ecf38 | ||
|
|
0dca019fe5 | ||
|
|
ca0d6805cf | ||
|
|
8bcb487442 | ||
|
|
0245ec67e1 | ||
|
|
8810a63262 | ||
|
|
d3778e6521 | ||
|
|
1fc6185002 | ||
|
|
4269d0589c | ||
|
|
2b1b06abd6 | ||
|
|
ac4a89120d | ||
|
|
704a259432 | ||
|
|
1c02970ae1 | ||
|
|
66b2036e37 | ||
|
|
bd2ffab3bc | ||
|
|
7142627cec | ||
|
|
1e7d2d880c | ||
|
|
63efd724d2 | ||
|
|
ba4781910d | ||
|
|
08c85a42e1 | ||
|
|
fc5f6f43d6 | ||
|
|
9b22ef810e | ||
|
|
dbc0eea148 | ||
|
|
e80412b6fd | ||
|
|
2c825af689 | ||
|
|
4cfee4f672 | ||
|
|
7076f1ded8 | ||
|
|
9fbcdf7a5f | ||
|
|
7f35d792b2 | ||
|
|
c9cda3e203 | ||
|
|
3540bf42d9 | ||
|
|
e6782e61a8 | ||
|
|
33e655bcce | ||
|
|
15a67a14b3 | ||
|
|
b48eef2189 | ||
|
|
280f5bc675 | ||
|
|
1756f5ac1c | ||
|
|
c9435f7580 | ||
|
|
426d344aa8 | ||
|
|
ed90e096b5 | ||
|
|
eb6e5f589e | ||
|
|
eca837962c | ||
|
|
b75bbaa517 | ||
|
|
9763ffa2a3 | ||
|
|
d854b903e0 | ||
|
|
5008857880 | ||
|
|
628966d3b5 | ||
|
|
d45c8ef511 | ||
|
|
ba90ec1f7a | ||
|
|
5023d31202 | ||
|
|
1c44f59ba1 | ||
|
|
433a63bf1a | ||
|
|
de847b9e9f | ||
|
|
d530d9614b | ||
|
|
9a92271f6f | ||
|
|
97ddf935a8 | ||
|
|
973ce1e5bd | ||
|
|
7003020855 | ||
|
|
5cdc5997af | ||
|
|
c3b4707f85 | ||
|
|
2b3094a4ef | ||
|
|
ecd0467ffa | ||
|
|
43ac0725ce | ||
|
|
98879802ae | ||
|
|
cf9292262d | ||
|
|
3d2ed71f3a | ||
|
|
dcd2f4b079 | ||
|
|
b2d24670ff | ||
|
|
fa261fecfd | ||
|
|
40f36baf94 | ||
|
|
400703154e | ||
|
|
3ceada7b8a | ||
|
|
eb0643d887 | ||
|
|
1fc670138b | ||
|
|
313a1fcce9 | ||
|
|
7efd8d59bf | ||
|
|
c2eb2c081b | ||
|
|
b299a0b602 | ||
|
|
f275fbe2e4 | ||
|
|
f134766fd1 | ||
|
|
6ebf720688 | ||
|
|
b1eb2b3c9b | ||
|
|
48aa607b56 | ||
|
|
088810bd20 | ||
|
|
e16f572252 | ||
|
|
9f312aa556 | ||
|
|
c5ac68e137 | ||
|
|
ed6b5d5f33 | ||
|
|
7a72903632 | ||
|
|
92127e2f00 | ||
|
|
be5ff96e4d | ||
|
|
1f47b7a6a9 | ||
|
|
551984ac44 | ||
|
|
8c5aec9ca4 | ||
|
|
375220298d | ||
|
|
452976ed95 | ||
|
|
243a2adfbf | ||
|
|
1ba4a13353 | ||
|
|
5de12dfb79 | ||
|
|
689d646c57 | ||
|
|
49cd7f8130 | ||
|
|
4545e212f4 | ||
|
|
b1f4f6eae0 | ||
|
|
9a431a873b | ||
|
|
bb7e1c4538 | ||
|
|
356abcfc2d | ||
|
|
3099b4801e | ||
|
|
fc641e94ac | ||
|
|
0e758409ee | ||
|
|
8c93941d8d | ||
|
|
4221c9a50f | ||
|
|
3a559479bd | ||
|
|
482493a10d | ||
|
|
0e1036eb49 | ||
|
|
349406723c | ||
|
|
997bedc91b | ||
|
|
307e6d5d34 | ||
|
|
ca1108f455 | ||
|
|
0ff88f36ed | ||
|
|
285b4e349c | ||
|
|
2b4ebb1fbb | ||
|
|
a829c2818f | ||
|
|
c54cf73c3a | ||
|
|
c45ef86924 | ||
|
|
f39366b470 | ||
|
|
b725000f4d | ||
|
|
105194a8b9 | ||
|
|
8dbb538c98 | ||
|
|
e435356c53 | ||
|
|
6f9982540c | ||
|
|
d0d6632b24 | ||
|
|
4b7009dfc2 | ||
|
|
55a408e810 | ||
|
|
d5dddba6df | ||
|
|
d1a0667c79 | ||
|
|
e700e4fd04 | ||
|
|
cf993c1036 | ||
|
|
5fdf226ebe | ||
|
|
fae15f073e | ||
|
|
c646c87030 | ||
|
|
b4a76bc4d1 | ||
|
|
dfe886636b | ||
|
|
f499e87d2a | ||
|
|
32fe871b70 | ||
|
|
aab19379e2 | ||
|
|
b46a1a69e8 | ||
|
|
ea424cad7e | ||
|
|
3f444a8912 | ||
|
|
8b0e2052cf | ||
|
|
c99934a92c | ||
|
|
ce2731cd9d | ||
|
|
f9f8af2f11 | ||
|
|
d8e2a10806 |
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
# LOGIN_WEBSITE_URL=https://example.com
|
||||
|
||||
# Per-domain branding overrides. Each entry must have "host" (exact or
|
||||
# "*.subdomain" wildcard) plus any subset of branding fields to override.
|
||||
# Unset fields fall through to the global values above.
|
||||
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
|
||||
|
||||
@@ -49,6 +49,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OpenID Connect issuer URL for discovery
|
||||
# OAUTH_ISSUER_URL=https://your-idp.example.com
|
||||
|
||||
# Allow OAuth discovery to resolve to private (RFC-1918 / loopback) addresses.
|
||||
# Off by default as an SSRF guard. Enable for split-DNS deployments where the
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
@@ -220,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-domain branding overrides (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When you serve the webmail on multiple hostnames, each hostname can override
|
||||
# a subset of branding fields. Unset fields fall back to the global values
|
||||
# above. Match is on the request's Host (or X-Forwarded-Host) header.
|
||||
#
|
||||
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
|
||||
# matches mail.example.com and any deeper subdomain, but NOT example.com).
|
||||
# Exact matches always win over wildcards; the longest wildcard suffix wins
|
||||
# among multiple wildcard matches.
|
||||
#
|
||||
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
|
||||
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
|
||||
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
|
||||
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
|
||||
#
|
||||
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
|
||||
# The env-var form is provided for stateless deployments.
|
||||
#
|
||||
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
|
||||
|
||||
# =============================================================================
|
||||
# Extension Directory / Marketplace
|
||||
# =============================================================================
|
||||
@@ -229,6 +257,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
# These are build-time variables - to change them with the published Docker
|
||||
# image, rebuild it with --build-arg (see README "Default UI locale").
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
|
||||
# Values: never (default) | always | as-needed
|
||||
# NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
|
||||
# =============================================================================
|
||||
# Legacy Build-time Variables (still supported as fallback)
|
||||
# =============================================================================
|
||||
|
||||
+266
@@ -1,5 +1,271 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.3 (2026-06-04)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Inline attachment preview — reliable MIME detection with inline PDF on desktop and mobile
|
||||
- **Mail**: Preview composer attachments inline (click to open)
|
||||
- **Mail**: Preview `.eml` (`message/rfc822`) attachments like an email
|
||||
- **Mail**: Read receipts (MDN, RFC 8098)
|
||||
- **Mail**: Editable, layout-preserving quote island when replying
|
||||
- **Mail**: Surface the most severe SPF result and hide the "via" badge on spoofed mail
|
||||
- **Calendar**: Per-viewer colors for shared calendars (#345)
|
||||
- **Filters**: Extended filter rules — attachment field and multi-value conditions
|
||||
- **Settings**: New built-in themes — Aurora Glass and Elastic
|
||||
- **Settings**: Theme cards render as a mini mailbox mockup from theme colors, with light/dark variant chips
|
||||
- **Plugins**: Localizable sandboxed plugins (manifest locales + `api.i18n.t`)
|
||||
- **Plugins**: `/api/translate` proxy and email body exposed to plugins
|
||||
- **Admin**: Toggle for search-engine indexing (robots)
|
||||
- **Admin**: `passwordHashFile` in `admin.json`
|
||||
- **Admin**: `sessionSecretFile` and `oauthClientSecretFile` for file-based secrets in JSON config
|
||||
- **PWA**: Configurable install screenshots (per-domain)
|
||||
- **i18n**: Hungarian locale support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Files**: Store Files as real `FileNode` hierarchy, migrate legacy flat-named files on load, and list folders via `FileNode/get` so they are visible (#379)
|
||||
- **Files**: Treat a blob-less `FileNode` as the only folder signal and migrate legacy dir-markers
|
||||
- **Mail**: Empty Trash for shared and group folders (#387)
|
||||
- **Mail**: Move mail from a shared group inbox to a personal inbox (#375)
|
||||
- **Mail**: Preserve the HTML signature when sending a quick reply
|
||||
- **Mail**: Stop body clipping under the fold when the email sets `html`/`body` `height: 100%`
|
||||
- **Mail**: Drop single-letter `R:`/`I:` subject prefix tokens and deduplicate localized reply/forward prefixes
|
||||
- **Mail**: No more 404 console spam for missing sender favicons
|
||||
- **Auth**: Discover OIDC metadata server-side to avoid CORS failures (#382)
|
||||
- **Send**: Route the Sent copy to the shared-mailbox account on per-identity send
|
||||
- **Routing**: Honour `basePath` in the plugin sandbox, `http.post` proxy, and branding
|
||||
- **i18n**: Localize the PWA install prompt, reply/forward quote header (incl. sender address), `<html lang>`, and per-locale `<head>` description; add missing `settings.folders.role_memos` key
|
||||
- **Themes**: Plugin slot iframes inherit host font and color tokens
|
||||
- **Theme**: Gate preview "open in new tab" on inline-safe MIME types
|
||||
- **Appearance**: Move Themes settings into the Appearance category with a distinct tab icon; clicking the active theme is a no-op
|
||||
- **UI**: Fix invisible dark-mode borders (border token collided with secondary)
|
||||
- **UI**: Remove the 16px empty strip beside the collapsed sidebar
|
||||
- **UI**: Align top bars to a uniform `h-14` height and the account selector header to the search/reply toolbars
|
||||
- **UI**: Close pane gaps by centering the resize handle on the seam
|
||||
- **Settings**: Fix section gears permanently hijacking the active tab
|
||||
|
||||
## 1.7.2 (2026-05-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **Mail**: Scheduled send and send delay (#322)
|
||||
- **Mail**: Drag emails out to the file explorer as `.eml`
|
||||
- **Mail**: Import emails from `.zip` archives
|
||||
- **Mail**: "Move to Trash and mark as read" delete action (#323)
|
||||
- **Mail**: Include group inboxes in the unified mailbox view (#328)
|
||||
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
|
||||
- **Mail**: Allow drag-and-drop into shared mailboxes
|
||||
- **Composer**: Ctrl/Cmd+Enter sends the open draft
|
||||
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
|
||||
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
|
||||
- **Settings**: Post-export action (keep / archive / trash)
|
||||
- **Settings**: Template for multi-email `.zip` filenames
|
||||
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
|
||||
- **Admin**: Policy-controlled push relay URL with optional user lock
|
||||
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
|
||||
- **Mail**: Report real upload progress with XHR progress events (#333)
|
||||
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
|
||||
- **Mail**: Match user-avatar treatment on quick reply
|
||||
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
|
||||
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
|
||||
- **Composer**: Stop closing the form when editing any field
|
||||
- **Pro**: Keep the empty viewer pane visible in the split layout
|
||||
- **Pro**: Prevent an empty main pane when reordering tabs across panes
|
||||
- **Mobile**: Collapse focus mail layout to multi-line
|
||||
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
|
||||
- **Calendar**: Align continued multi-week events with the week's left edge
|
||||
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
|
||||
- **Calendar**: Convert `recurrenceRules` to singular in batch create
|
||||
- **Calendar**: Handle malformed event dates (#316)
|
||||
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
|
||||
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
|
||||
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
|
||||
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
|
||||
|
||||
### i18n
|
||||
|
||||
- Add missing translation keys across 16 locales
|
||||
|
||||
## 1.7.1 (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
- **Admin**: Expose PWA branding fields in the admin Branding tab
|
||||
- **Pro**: Hide empty-state placeholder and collapse the viewer pane in Pro mode so the mail list fills the space
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Preserve inline images when replying (#163)
|
||||
- **Filters**: Use the canonical `INBOX` mailbox in Sieve filter paths (#313)
|
||||
- **Mail**: Resolve destination account id to the local namespace on cross-account mailbox drop
|
||||
|
||||
## 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
|
||||
- **Plugins**: Marketplace update flow for installed plugins and themes
|
||||
- **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`
|
||||
- **Plugins**: Trust the directory version on marketplace install and update
|
||||
- **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
|
||||
|
||||
+26
-34
@@ -10,14 +10,17 @@
|
||||
|
||||
# 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
|
||||
**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.
|
||||
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.
|
||||
|
||||
* **Get Support:** Get real-time help with development hurdles.
|
||||
* **Contribute:** Share ideas, suggest features, or help us improve documentation.
|
||||
* **Collaborate:** Meet the team and other contributors working to make Bulwark better.
|
||||
## Join the Community
|
||||
|
||||
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)
|
||||
|
||||
@@ -94,37 +97,31 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## 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
|
||||
const t = useTranslations("namespace");
|
||||
return <div>{t("key")}</div>;
|
||||
```
|
||||
|
||||
2. **Translation file locations**:
|
||||
- English: `/locales/en/common.json`
|
||||
- French: `/locales/fr/common.json`
|
||||
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.
|
||||
|
||||
3. **Namespace organization**:
|
||||
- `login.*` - Login page strings
|
||||
- `sidebar.*` - Sidebar navigation
|
||||
- `email_list.*` - Email list component
|
||||
- `email_viewer.*` - Email viewer component
|
||||
- `email_composer.*` - Email composer
|
||||
- `common.*` - Shared strings
|
||||
- `notifications.*` - Toast/alert messages
|
||||
- `settings.*` - Settings page
|
||||
- `login.*` - login page
|
||||
- `sidebar.*` - sidebar navigation
|
||||
- `email_list.*` - email list
|
||||
- `email_viewer.*` - email viewer
|
||||
- `email_composer.*` - composer
|
||||
- `settings.*` - settings page
|
||||
- `notifications.*` - toasts and alerts
|
||||
- `common.*` - shared strings
|
||||
|
||||
4. **Adding new strings**:
|
||||
- Add to **both** English and French translation files
|
||||
- Use descriptive, hierarchical keys
|
||||
- Keep translations consistent in tone
|
||||
4. **Locale-aware navigation**:
|
||||
|
||||
5. **Locale-aware navigation**:
|
||||
```tsx
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
@@ -203,16 +200,11 @@ webmail/
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit sensitive data** (API keys, passwords, etc.)
|
||||
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
|
||||
- **Sanitize user input** and email content
|
||||
- **Block external content** by default for privacy
|
||||
- Report security vulnerabilities privately (e.g. bulwark@rbm.systems)
|
||||
- **Block external content** by default - privacy is the point
|
||||
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions about contributing, feel free to:
|
||||
|
||||
- Open an issue for discussion
|
||||
- Check existing issues and pull requests
|
||||
|
||||
Thank you for helping improve Bulwark Webmail!
|
||||
Open an issue, search existing ones, or ask in Discord. Thanks for helping build the webmail we all wished existed.
|
||||
|
||||
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||
# Accept-Language header does not match any supported locale. Baked in at
|
||||
# build time because next-intl wires it into client-side routing too.
|
||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# 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
|
||||
|
||||
+14
-8
@@ -7,7 +7,10 @@
|
||||
- Unified mailbox view across all connected accounts
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- 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
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||
- Scheduled send and configurable send delay
|
||||
- Read receipts (MDN, RFC 8098)
|
||||
- Editable, layout-preserving quote island when replying
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
@@ -36,7 +39,7 @@
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery and multi-account home resolution
|
||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
|
||||
@@ -52,7 +55,7 @@
|
||||
## Filters & Templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
@@ -60,7 +63,7 @@
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage)
|
||||
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
@@ -72,7 +75,7 @@
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators
|
||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
@@ -85,6 +88,7 @@
|
||||
|
||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
@@ -98,7 +102,7 @@
|
||||
|
||||
## Internationalization
|
||||
|
||||
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
@@ -118,7 +122,9 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
- 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
|
||||
- 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
|
||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, 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
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||
@@ -126,7 +132,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, and dynamic manifest
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||
- 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
|
||||
- Anonymous instance telemetry (opt-out via admin UI or `BULWARK_TELEMETRY=off`) – version, platform, bucketed account counts, feature toggles only
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
@@ -86,7 +86,7 @@ Bulwark is a full webmail suite, not just an inbox. It bundles the four apps mos
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
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.
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# Per-domain overrides (optional). When the webmail is served on multiple
|
||||
# hostnames, each host can override any subset of the branding fields above.
|
||||
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
|
||||
# match any subdomain. Unset fields fall back to the global values.
|
||||
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -78,7 +78,69 @@ function OAuthCallbackInner() {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} 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 apiFetch("/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);
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
@@ -15,8 +15,9 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-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 { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
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 { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||
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 { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-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 { 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 { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
@@ -55,7 +60,10 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
|
||||
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: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||
@@ -68,9 +76,17 @@ function isRecurringEvent(event: CalendarEvent): boolean {
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("calendar");
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
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 { 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 { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
@@ -82,6 +98,9 @@ export default function CalendarPage() {
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
const taskStore = useTaskStore();
|
||||
const fetchTasksFn = useTaskStore(state => state.fetchTasks);
|
||||
const { identities } = useIdentityStore();
|
||||
@@ -96,6 +115,10 @@ export default function CalendarPage() {
|
||||
const [showEventModal, setShowEventModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = 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 [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
@@ -156,10 +179,10 @@ export default function CalendarPage() {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
redirectToLogin();
|
||||
} else if (client && !supportsCalendar) {
|
||||
} else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -167,12 +190,95 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
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) {
|
||||
hasFetched.current = true;
|
||||
fetchCalendars(client);
|
||||
}
|
||||
}, [client, fetchCalendars]);
|
||||
}, [client, fetchCalendars, isEmbedded]);
|
||||
|
||||
// Auto-refresh iCal subscriptions
|
||||
useEffect(() => {
|
||||
@@ -241,10 +347,21 @@ export default function CalendarPage() {
|
||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
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(() => {
|
||||
let next: Date;
|
||||
@@ -316,6 +433,8 @@ export default function CalendarPage() {
|
||||
setMobileReturnToMonth(true);
|
||||
setViewMode("day");
|
||||
}
|
||||
// Close the narrow-pane sidebar overlay after the user picks a date.
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||
|
||||
const navigateBackToMonth = useCallback(() => {
|
||||
@@ -466,12 +585,15 @@ export default function CalendarPage() {
|
||||
}, [events, client]);
|
||||
|
||||
const refetchCurrentRange = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (!client || !activeAccountId) return;
|
||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||
if (currentRange) {
|
||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||
if (!currentRange) return;
|
||||
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)
|
||||
// and refresh calendar data via JMAP instead of reloading the page.
|
||||
@@ -479,8 +601,11 @@ export default function CalendarPage() {
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||
: fetchCalendars(client);
|
||||
await Promise.all([
|
||||
fetchCalendars(client),
|
||||
calendarRefresh,
|
||||
refetchCurrentRange(),
|
||||
refreshAllSubscriptions(client),
|
||||
]);
|
||||
@@ -909,10 +1034,47 @@ export default function CalendarPage() {
|
||||
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
|
||||
})();
|
||||
|
||||
// Apply each shared calendar's local color override (per-viewer recolor,
|
||||
// #345). The override replaces the calendar's color and wins over per-event
|
||||
// colors via the `colorIsLocalOverride` flag (see getEventColor). Personal
|
||||
// calendars are passed through untouched.
|
||||
const displayCalendars = useMemo(() => {
|
||||
return calendars.map((cal) => {
|
||||
if (!cal.isShared) return cal;
|
||||
const override = sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
if (!override) return cal;
|
||||
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||
});
|
||||
}, [calendars, sharedCalendarColors]);
|
||||
|
||||
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||
// once per calendar (guarded by the presence of an existing key), and the
|
||||
// user can still overwrite it from the sidebar.
|
||||
useEffect(() => {
|
||||
const shared = calendars.filter((c) => c.isShared);
|
||||
const missing = shared.filter((c) => !sharedCalendarColors[sharedCalendarColorKey(c)]);
|
||||
if (missing.length === 0) return;
|
||||
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||
// overrides so the picks stay distinct from what's already on screen.
|
||||
const used = new Set<string>();
|
||||
for (const c of calendars) {
|
||||
if (!c.isShared && c.color) used.add(c.color.toLowerCase());
|
||||
}
|
||||
for (const color of Object.values(sharedCalendarColors)) {
|
||||
if (color) used.add(color.toLowerCase());
|
||||
}
|
||||
for (const cal of missing) {
|
||||
const color = pickUnusedCalendarColor(used);
|
||||
used.add(color.toLowerCase());
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
}
|
||||
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||
|
||||
const allCalendars = useMemo(() => {
|
||||
if (!showBirthdayCalendar) return calendars;
|
||||
return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
if (!showBirthdayCalendar) return displayCalendars;
|
||||
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
|
||||
const visibleEvents = useMemo(() => {
|
||||
const filtered = events.filter((e) => {
|
||||
@@ -955,7 +1117,54 @@ export default function CalendarPage() {
|
||||
});
|
||||
}, [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 = () => {
|
||||
if (isLoading && calendars.length === 0) {
|
||||
@@ -1051,7 +1260,7 @@ export default function CalendarPage() {
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
showCompleted={taskStore.showCompleted}
|
||||
@@ -1082,9 +1291,11 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail */}
|
||||
{!isMobile && (
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<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)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -1103,15 +1314,31 @@ export default function CalendarPage() {
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Sidebar - full height */}
|
||||
{!isMobile && !inlineApp && (
|
||||
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
|
||||
{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
|
||||
className={cn(
|
||||
"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
|
||||
selectedDate={selectedDate}
|
||||
@@ -1131,8 +1358,21 @@ export default function CalendarPage() {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
return;
|
||||
}
|
||||
// Shared calendars: recolor locally only (the viewer usually
|
||||
// can't write the owner's calendar, and it'd recolor it for
|
||||
// everyone). Personal calendars write through to the server.
|
||||
const cal = allCalendars.find((c) => c.id === calendarId);
|
||||
if (cal?.isShared) {
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
return;
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onResetColor={(cal) => {
|
||||
// Drop the local override; the auto-assign effect picks a
|
||||
// fresh unused color (so it never reverts to a collision).
|
||||
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||
}}
|
||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||
onCreateEvent={(cal: Calendar) => {
|
||||
setDefaultCalendarIdForCreate(cal.id);
|
||||
@@ -1172,17 +1412,20 @@ export default function CalendarPage() {
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||
/>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1200,10 +1443,11 @@ export default function CalendarPage() {
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
isMobile={isMobile}
|
||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -1229,7 +1473,7 @@ export default function CalendarPage() {
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
@@ -1252,7 +1496,7 @@ export default function CalendarPage() {
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||
@@ -1276,7 +1520,7 @@ export default function CalendarPage() {
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<div className="shrink-0">
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
@@ -1339,7 +1583,7 @@ export default function CalendarPage() {
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
||||
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
|
||||
anchorRect={detailAnchorRect}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -1359,7 +1603,7 @@ export default function CalendarPage() {
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
event={editEvent}
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
@@ -1376,16 +1620,25 @@ export default function CalendarPage() {
|
||||
|
||||
{showImportModal && client && (
|
||||
<ICalImportModal
|
||||
calendars={calendars}
|
||||
calendars={displayCalendars}
|
||||
client={client}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
onClose={() => setShowSubscriptionModal(false)}
|
||||
initialUrl={pendingSubscription?.url}
|
||||
initialName={pendingSubscription?.name}
|
||||
onClose={() => {
|
||||
setShowSubscriptionModal(false);
|
||||
setPendingSubscription(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1402,6 +1655,8 @@ export default function CalendarPage() {
|
||||
})()}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{renderWebcalAccountPicker()}
|
||||
{renderWebcalActionChoice()}
|
||||
<RecurrenceScopeDialog
|
||||
isOpen={!!pendingScopeAction}
|
||||
actionType={pendingScopeAction?.type || "edit"}
|
||||
@@ -1435,6 +1690,7 @@ export default function CalendarPage() {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
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 { ConfirmDialog } from "@/components/ui/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 { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
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 { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||
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 type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
@@ -42,6 +48,7 @@ type View =
|
||||
|
||||
export default function ContactsPage() {
|
||||
const t = useTranslations("contacts");
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
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);
|
||||
@@ -88,11 +95,26 @@ export default function ContactsPage() {
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
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 [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
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)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
@@ -129,12 +151,41 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && supportsSync && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
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)
|
||||
// and refresh contacts via JMAP instead of reloading the page.
|
||||
@@ -142,6 +193,17 @@ export default function ContactsPage() {
|
||||
enabled: isAuthenticated && !!client && supportsSync,
|
||||
onRefresh: async () => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -193,6 +255,7 @@ export default function ContactsPage() {
|
||||
} else {
|
||||
setSelectedGroupId(null);
|
||||
}
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [clearSelection]);
|
||||
|
||||
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||
@@ -335,8 +398,14 @@ export default function ContactsPage() {
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setCreatePrefill(undefined);
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
|
||||
|
||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (!selectedContact) return;
|
||||
@@ -353,6 +422,14 @@ export default function ContactsPage() {
|
||||
|
||||
const handleCancel = () => {
|
||||
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") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
@@ -524,7 +601,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
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":
|
||||
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 showRightPanel = !isMobile || view !== "list";
|
||||
|
||||
const mobileBackToList = () => {
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
setCreatePrefill(undefined);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only */}
|
||||
{!isMobile && (
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<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)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -670,18 +767,33 @@ export default function ContactsPage() {
|
||||
{inlineApp && (
|
||||
<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 && (
|
||||
<>
|
||||
{/* Panel 1: Categories sidebar */}
|
||||
{!isMobile && (
|
||||
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
|
||||
{(!isMobile || isNarrow) && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border flex flex-col flex-shrink-0",
|
||||
!isSidebarResizing && "transition-[width] duration-300"
|
||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!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
|
||||
groups={groups}
|
||||
@@ -718,17 +830,20 @@ export default function ContactsPage() {
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsSidebarResizing(false);
|
||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsSidebarResizing(false);
|
||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -761,6 +876,7 @@ export default function ContactsPage() {
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -790,7 +906,7 @@ export default function ContactsPage() {
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_contacts")}
|
||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -801,7 +917,7 @@ export default function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -882,6 +998,7 @@ export default function ContactsPage() {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-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 { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -24,13 +26,17 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
export default function FilesPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
@@ -42,9 +48,11 @@ export default function FilesPage() {
|
||||
supportsFiles,
|
||||
selectedResources,
|
||||
uploadProgress,
|
||||
migrationProgress,
|
||||
clipboard,
|
||||
initClient,
|
||||
checkSupport,
|
||||
migrateLegacyFlatNodes,
|
||||
navigate,
|
||||
navigateByPath,
|
||||
refresh,
|
||||
@@ -83,6 +91,7 @@ export default function FilesPage() {
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
@@ -127,13 +136,18 @@ export default function FilesPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (isAuthenticated && client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
initClient(client);
|
||||
if (!isAuthenticated || !client || hasFetched.current) return;
|
||||
hasFetched.current = true;
|
||||
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)
|
||||
// and refresh files via JMAP instead of reloading the page.
|
||||
@@ -148,15 +162,29 @@ export default function FilesPage() {
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
useEffect(() => {
|
||||
if (storeClient && supportsFiles === null) {
|
||||
checkSupport().then((supported) => {
|
||||
checkSupport().then(async (supported) => {
|
||||
if (supported) {
|
||||
// Upgrade any files created by older builds (flat path-encoded names)
|
||||
// into the real FileNode hierarchy before the first listing.
|
||||
await migrateLegacyFlatNodes();
|
||||
navigate(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
|
||||
|
||||
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) {
|
||||
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
||||
navigate(resourceId, path.split('/').pop() || '');
|
||||
@@ -164,7 +192,7 @@ export default function FilesPage() {
|
||||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||||
navigateByPath(path);
|
||||
}
|
||||
}, [navigate, navigateByPath]);
|
||||
}, [navigate, navigateByPath, isEmbedded]);
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
@@ -371,11 +399,45 @@ export default function FilesPage() {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh bg-background overflow-hidden">
|
||||
{!isMobile && (
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<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)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -396,7 +458,7 @@ export default function FilesPage() {
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<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="flex items-center justify-between">
|
||||
<Button
|
||||
@@ -474,6 +536,10 @@ export default function FilesPage() {
|
||||
showDetails={showDetails}
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
accountFolders={accountFolders}
|
||||
onSelectAccount={handleSelectAccount}
|
||||
accountPickerMode={isAccountPicker}
|
||||
accountLabel={currentAccountLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -481,7 +547,7 @@ export default function FilesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -512,8 +578,35 @@ export default function FilesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Legacy file migration progress (issue #379) */}
|
||||
{migrationProgress && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("migration_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style={{ width: migrationProgress.total > 0
|
||||
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
|
||||
{migrationProgress.current} / {migrationProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { CalendarAlertProvider } from "@/components/providers/calendar-alert-pro
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-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 { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -32,7 +37,13 @@ export default async function LocaleLayout({
|
||||
<RateLimitToastProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
{children}
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
<ProInterfaceRedirect />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
<PWAInstallPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</RateLimitToastProvider>
|
||||
@@ -11,12 +11,11 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
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";
|
||||
|
||||
@@ -109,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() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
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 { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = 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 [formData, setFormData] = useState({
|
||||
@@ -160,6 +176,9 @@ export default function LoginPage() {
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevError = useRef<string | null>(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(() => {
|
||||
initializeTheme();
|
||||
@@ -239,6 +258,19 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isAddAccountMode) {
|
||||
// Mobile handoff: the password path completes here once the auth store
|
||||
// flips isAuthenticated. Hand the verified credentials back to the
|
||||
// mobile app instead of pushing to /mail. handleSubmit captured the
|
||||
// values needed for the fragment.
|
||||
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
|
||||
const fragment = new URLSearchParams({
|
||||
flow: "password",
|
||||
...mobileHandoffPayloadRef.current,
|
||||
state: mobileState,
|
||||
});
|
||||
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||
return;
|
||||
}
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
@@ -249,7 +281,7 @@ export default function LoginPage() {
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, router, isAddAccountMode]);
|
||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
@@ -297,16 +329,27 @@ export default function LoginPage() {
|
||||
if (!oauthEnabled || !serverUrl) return;
|
||||
setOauthDiscoveryDone(false);
|
||||
setOauthMetadata(null);
|
||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||
const controller = new AbortController();
|
||||
// Discover via our own origin rather than fetching the IdP's /.well-known/*
|
||||
// documents directly from the browser. A direct cross-origin discovery
|
||||
// fetch is subject to CORS, and providers like Authentik serve those
|
||||
// documents without Access-Control-Allow-Origin, so the browser blocks the
|
||||
// response and login breaks (issue #382). The proxy runs discovery server
|
||||
// side where CORS does not apply.
|
||||
const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : "";
|
||||
apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal })
|
||||
.then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null))
|
||||
.then((metadata) => {
|
||||
setOauthMetadata(metadata);
|
||||
setOauthDiscoveryDone(true);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
if (err?.name === "AbortError") return;
|
||||
setOauthMetadata(null);
|
||||
setOauthDiscoveryDone(true);
|
||||
});
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||
return () => controller.abort();
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]);
|
||||
|
||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||
const ssoError = searchParams.get("sso_error");
|
||||
@@ -317,6 +360,16 @@ export default function LoginPage() {
|
||||
try {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -325,6 +378,9 @@ export default function LoginPage() {
|
||||
redirect_uri: redirectUri,
|
||||
locale: params.locale,
|
||||
server_id: selectedServer?.id,
|
||||
...(isMobileHandoff
|
||||
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -351,7 +407,7 @@ export default function LoginPage() {
|
||||
} catch {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
}, [params.locale, selectedServer?.id]);
|
||||
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||
@@ -493,6 +549,15 @@ export default function LoginPage() {
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
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);
|
||||
|
||||
const verifier = generateCodeVerifier();
|
||||
@@ -532,7 +597,7 @@ export default function LoginPage() {
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||
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("code_challenge", challenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
@@ -547,6 +612,16 @@ export default function LoginPage() {
|
||||
// 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(
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
@@ -557,7 +632,15 @@ export default function LoginPage() {
|
||||
|
||||
if (success) {
|
||||
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('/');
|
||||
} else if (isMobileHandoff) {
|
||||
// Stale payload should never feed into a later retry's redirect.
|
||||
mobileHandoffPayloadRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -650,7 +733,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -800,7 +883,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
/>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
"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 { getPathPrefix } from "@/lib/browser-navigation";
|
||||
|
||||
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(`${getPathPrefix()}/`);
|
||||
}
|
||||
}, [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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,18 +26,22 @@ import {
|
||||
Bell,
|
||||
Puzzle,
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
EyeOff,
|
||||
Languages,
|
||||
Info,
|
||||
Bug,
|
||||
SwatchBook,
|
||||
Download,
|
||||
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';
|
||||
@@ -57,12 +61,14 @@ 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 { DownloadsSettings } from '@/components/settings/downloads-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';
|
||||
@@ -73,6 +79,7 @@ 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';
|
||||
@@ -86,6 +93,7 @@ type Tab =
|
||||
| 'layout'
|
||||
| 'reading'
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
@@ -98,6 +106,7 @@ type Tab =
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
| 'files'
|
||||
| 'protocol_handlers'
|
||||
| 'sidebar_apps'
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
@@ -121,6 +130,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
layout: LayoutGrid,
|
||||
reading: BookOpen,
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
@@ -133,9 +143,10 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
protocol_handlers: LinkIcon,
|
||||
sidebar_apps: PanelLeftClose,
|
||||
about_data: Info,
|
||||
themes: Palette,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
debug: Bug,
|
||||
};
|
||||
@@ -155,6 +166,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.account.email',
|
||||
'settings.account.server',
|
||||
'settings.account.storage',
|
||||
'settings.account.accounts',
|
||||
],
|
||||
language: ['settings.appearance.language'],
|
||||
notifications: ['settings.notifications'],
|
||||
@@ -195,6 +207,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.email_behavior.signature_position',
|
||||
'settings.email_behavior.sub_address_delimiter',
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
@@ -211,6 +224,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
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: [],
|
||||
@@ -221,13 +235,14 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
// 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',
|
||||
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',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
@@ -240,6 +255,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
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',
|
||||
@@ -320,6 +336,14 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
||||
|
||||
function readPersistedTab(): Tab {
|
||||
try {
|
||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||
// Used only as the initial tab and intentionally NOT written to
|
||||
// 'settings-active-tab', so a gear click never becomes the persisted
|
||||
// default that the regular Settings button lands on. Cleared on mount.
|
||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||
if (deepLink) {
|
||||
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
|
||||
}
|
||||
const saved = localStorage.getItem('settings-active-tab');
|
||||
if (!saved) return 'appearance';
|
||||
if (saved in LEGACY_TAB_MAP) {
|
||||
@@ -339,11 +363,17 @@ export default function SettingsPage() {
|
||||
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);
|
||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||
// open, never the persisted default for future Settings-button clicks.
|
||||
useEffect(() => {
|
||||
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
|
||||
@@ -353,6 +383,7 @@ export default function SettingsPage() {
|
||||
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
|
||||
@@ -560,14 +591,17 @@ export default function SettingsPage() {
|
||||
{ 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' },
|
||||
...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []),
|
||||
|
||||
// Mail
|
||||
{ id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' },
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, 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 }] : []),
|
||||
@@ -582,13 +616,12 @@ export default function SettingsPage() {
|
||||
|
||||
// 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('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 }] : []),
|
||||
];
|
||||
@@ -654,6 +687,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'layout' && <LayoutSettings />}
|
||||
{effectiveActiveTab === 'reading' && <ReadingSettings />}
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
@@ -666,6 +700,7 @@ export default function SettingsPage() {
|
||||
{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 />}
|
||||
@@ -678,7 +713,8 @@ export default function SettingsPage() {
|
||||
if (!isDesktop) {
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background">
|
||||
<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"
|
||||
@@ -695,20 +731,23 @@ export default function SettingsPage() {
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
{!isEmbedded && (
|
||||
<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={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"
|
||||
@@ -804,13 +843,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
@@ -818,19 +859,23 @@ export default function SettingsPage() {
|
||||
|
||||
// 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>
|
||||
<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" />
|
||||
@@ -844,17 +889,19 @@ export default function SettingsPage() {
|
||||
)}
|
||||
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>
|
||||
{!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">
|
||||
@@ -900,7 +947,7 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onClick={() => handleTabSelect(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
|
||||
@@ -950,6 +997,7 @@ export default function SettingsPage() {
|
||||
</>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
-19
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
const lastEmittedRef = useRef(value);
|
||||
|
||||
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);
|
||||
});
|
||||
if (value === lastEmittedRef.current) return;
|
||||
setDrafts(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);
|
||||
}
|
||||
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||
lastEmittedRef.current = entries;
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
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';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
export function AuthTab() {
|
||||
@@ -267,8 +271,11 @@ export function AuthTab() {
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
<Toggle label="Allow private OAuth endpoints" description="Permit discovery to resolve to RFC-1918 / loopback hosts. Enable only for split-DNS deployments where the mail server's public hostname resolves to an internal IP." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. 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 title="Single Sign-On">
|
||||
@@ -0,0 +1,721 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import {
|
||||
BRANDING_OVERRIDE_KEYS,
|
||||
parseDomainBranding,
|
||||
type BrandingOverrideKey,
|
||||
type DomainBrandingEntry,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
interface ConfigEntry {
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
const IMAGE_FIELDS = [
|
||||
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
] as const;
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
] as const;
|
||||
|
||||
const PWA_IMAGE_FIELDS = [
|
||||
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
|
||||
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
|
||||
] as const;
|
||||
|
||||
const PWA_TEXT_FIELDS = [
|
||||
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
|
||||
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
|
||||
] as const;
|
||||
|
||||
const PWA_COLOR_FIELDS = [
|
||||
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||
] as const;
|
||||
|
||||
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
|
||||
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
// Tighter rule for uploads: wildcards can only point to externally-hosted
|
||||
// URLs, since we'd have no concrete subdomain to serve a file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
const [newHostError, setNewHostError] = useState<string | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const domainEntries = useMemo<DomainBrandingEntry[]>(
|
||||
() => parseDomainBranding(config['domainBranding']?.value),
|
||||
[config],
|
||||
);
|
||||
|
||||
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
|
||||
useEffect(() => {
|
||||
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
}
|
||||
}, [domainEntries, selectedHost]);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function selectedEntry(): DomainBrandingEntry | null {
|
||||
if (!selectedHost) return null;
|
||||
return domainEntries.find(e => e.host === selectedHost) ?? null;
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key];
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
|
||||
}
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
function isOverriddenInScope(key: string): boolean {
|
||||
if (selectedHost) {
|
||||
const entry = selectedEntry();
|
||||
const v = entry?.[key as BrandingOverrideKey];
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}
|
||||
return config[key]?.source === 'admin';
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
|
||||
if (!selectedHost) return domainEntries;
|
||||
const next = domainEntries.slice();
|
||||
const idx = next.findIndex(e => e.host === selectedHost);
|
||||
const base: DomainBrandingEntry =
|
||||
idx === -1 ? { host: selectedHost } : { ...next[idx] };
|
||||
const writable = base as unknown as Record<string, string | undefined>;
|
||||
for (const [key, value] of Object.entries(merge)) {
|
||||
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
writable[key] = value;
|
||||
} else {
|
||||
delete writable[key];
|
||||
}
|
||||
}
|
||||
if (idx === -1) next.push(base);
|
||||
else next[idx] = base;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const payload = selectedHost
|
||||
? { domainBranding: buildUpdatedDomainBranding(edits) }
|
||||
: edits;
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: selectedHost
|
||||
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
|
||||
: 'Branding updated. Changes visible on next page load.',
|
||||
});
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
if (selectedHost) formData.append('host', selectedHost);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
// Refresh from server so domainBranding entries reflect the upload.
|
||||
await fetchConfig();
|
||||
void data;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
setUploading(null);
|
||||
}
|
||||
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const body: { slot: string; host?: string } = { slot };
|
||||
if (selectedHost) body.host = selectedHost;
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
if (selectedHost) {
|
||||
// Domain scope: drop the field from the entry and PATCH the array.
|
||||
const updated = buildUpdatedDomainBranding({ [key]: '' });
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: updated }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Default scope: revert via DELETE /api/admin/config
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddDomain() {
|
||||
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
|
||||
if (!host) {
|
||||
setNewHostError('Enter a hostname');
|
||||
return;
|
||||
}
|
||||
if (!HOST_RE.test(host)) {
|
||||
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
|
||||
return;
|
||||
}
|
||||
if (domainEntries.some(e => e.host === host)) {
|
||||
setNewHostError('A branding entry for this host already exists');
|
||||
return;
|
||||
}
|
||||
setNewHostError(null);
|
||||
|
||||
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewHostInput('');
|
||||
setAddingHost(false);
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setNewHostError(data.error || 'Failed to add domain');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDomain() {
|
||||
if (!selectedHost) return;
|
||||
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
|
||||
return;
|
||||
}
|
||||
const next = domainEntries.filter(e => e.host !== selectedHost);
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domainBranding: next }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSelectedHost(null);
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleScopeChange(host: string | null) {
|
||||
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
|
||||
setSelectedHost(host);
|
||||
setEdits({});
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope picker */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-medium text-foreground">Scope</h2>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(null)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{domainEntries.map(entry => (
|
||||
<button
|
||||
key={entry.host}
|
||||
type="button"
|
||||
onClick={() => handleScopeChange(entry.host)}
|
||||
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
selectedHost === entry.host
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{entry.host}
|
||||
</button>
|
||||
))}
|
||||
{!addingHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(true); setNewHostError(null); }}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add domain
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addingHost && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newHostInput}
|
||||
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
|
||||
placeholder="mail.example.com or *.example.com"
|
||||
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddDomain}
|
||||
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
|
||||
</div>
|
||||
)}
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
|
||||
Unset fields fall back to the Default values.
|
||||
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteDomain}
|
||||
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Remove domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{PWA_IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key || wildcardScope}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={withBasePath(currentValue(field.key))}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{PWA_TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{PWA_COLOR_FIELDS.map(field => {
|
||||
const value = currentValue(field.key) || field.defaultValue;
|
||||
return (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
|
||||
title="Pick a color"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue}
|
||||
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{selectedHost ? 'domain' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{isOverriddenInScope(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function DashboardTab() {
|
||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [themeCount, setThemeCount] = useState(0);
|
||||
@@ -96,7 +96,9 @@ export function DashboardTab() {
|
||||
const sources = await adminConfigRes.json();
|
||||
setConfigSources(sources);
|
||||
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.');
|
||||
}
|
||||
const adminPassword = sources?.adminPassword;
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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 { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
interface Extension {
|
||||
slug: string;
|
||||
@@ -18,6 +21,7 @@ interface Extension {
|
||||
minAppVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
@@ -94,6 +98,15 @@ export function MarketplaceTab() {
|
||||
}, [searchInput]);
|
||||
|
||||
async function handleInstall(ext: Extension) {
|
||||
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isUpdate = ext.installed;
|
||||
const targetVersion = ext.latestVersion || '1.0.0';
|
||||
setInstalling(ext.slug);
|
||||
setMessage(null);
|
||||
|
||||
@@ -103,7 +116,7 @@ export function MarketplaceTab() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: ext.slug,
|
||||
version: ext.latestVersion || '1.0.0',
|
||||
version: targetVersion,
|
||||
type: ext.type,
|
||||
}),
|
||||
});
|
||||
@@ -112,13 +125,22 @@ export function MarketplaceTab() {
|
||||
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${ext.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${ext.name}" installed successfully${warnings}`,
|
||||
});
|
||||
setExtensions(prev => prev.map(e =>
|
||||
e.slug === ext.slug
|
||||
? { ...e, installed: true, installedVersion: targetVersion }
|
||||
: e,
|
||||
));
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
@@ -258,6 +280,13 @@ function ExtensionCard({
|
||||
}) {
|
||||
const isPlugin = extension.type === 'plugin';
|
||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||
const versionMismatch = !!extension.minAppVersion
|
||||
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||
const updateAvailable = extension.installed
|
||||
&& !!extension.installedVersion
|
||||
&& !!extension.latestVersion
|
||||
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
@@ -346,12 +375,37 @@ function ExtensionCard({
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="px-4 pb-4 -mt-1">
|
||||
{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">
|
||||
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||
{extension.installed && updateAvailable ? (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
)}
|
||||
Update to v{extension.latestVersion}
|
||||
</button>
|
||||
) : extension.installed ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
|
||||
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
Installed
|
||||
</span>
|
||||
) : versionMismatch ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
|
||||
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Requires v{extension.minAppVersion}+
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
+23
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
|
||||
interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
@@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginAdminSection pluginId={pluginId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
|
||||
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
|
||||
* and the host gives it a dedicated iframe inside the admin panel.
|
||||
*/
|
||||
function PluginAdminSection({ pluginId }: { pluginId: string }) {
|
||||
const offers = usePluginSlotOffers('admin-plugin-page');
|
||||
const offer = offers.find((o) => o.pluginId === pluginId);
|
||||
if (!offer) return null;
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Plugin admin panel
|
||||
</div>
|
||||
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const RESTRICTABLE_SETTINGS = [
|
||||
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
|
||||
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
|
||||
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
|
||||
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
|
||||
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
|
||||
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
|
||||
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
|
||||
@@ -74,6 +74,18 @@ export function PolicyTab() {
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setPushRelayUrl(value: string) {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function togglePushRelayLocked() {
|
||||
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleLocked(settingKey: string) {
|
||||
setPolicy(prev => {
|
||||
const existing = prev.restrictions[settingKey] || {};
|
||||
@@ -183,6 +195,34 @@ export function PolicyTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={policy.pushRelayUrl ?? ''}
|
||||
onChange={(e) => setPushRelayUrl(e.target.value)}
|
||||
placeholder="https://notifications.relay.example.com"
|
||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!policy.pushRelayUrlLocked}
|
||||
onChange={togglePushRelayLocked}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
hasValue?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsTab() {
|
||||
@@ -124,6 +125,7 @@ export function SettingsTab() {
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
} 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 } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
@@ -87,10 +88,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
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'
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
@@ -177,6 +179,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
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">
|
||||
@@ -274,39 +282,41 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="w-7 h-7 mb-2" />
|
||||
)}
|
||||
<a
|
||||
href="/"
|
||||
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="/calendar"
|
||||
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="/contacts"
|
||||
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>
|
||||
<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>
|
||||
{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="/settings"
|
||||
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"
|
||||
>
|
||||
@@ -411,7 +421,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
@@ -419,7 +429,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
|
||||
</a>
|
||||
<a
|
||||
href="/calendar"
|
||||
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"
|
||||
>
|
||||
@@ -427,21 +437,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
|
||||
</a>
|
||||
<a
|
||||
href="/contacts"
|
||||
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>
|
||||
<a
|
||||
href="/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>
|
||||
{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"
|
||||
@@ -454,7 +466,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
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"
|
||||
>
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
+64
-9
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Download,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
@@ -21,6 +22,9 @@ import {
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
interface PreviewData {
|
||||
extension: {
|
||||
@@ -70,6 +74,7 @@ interface PreviewData {
|
||||
error: string | null;
|
||||
};
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
}
|
||||
|
||||
const RISKY_PERMISSIONS = new Set([
|
||||
@@ -115,6 +120,8 @@ export default function MarketplacePreviewPage() {
|
||||
|
||||
async function handleInstall() {
|
||||
if (!data) return;
|
||||
const isUpdate = data.installed;
|
||||
const targetVersion = data.extension.latestVersion || '1.0.0';
|
||||
setInstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
@@ -123,20 +130,25 @@ export default function MarketplacePreviewPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: data.extension.slug,
|
||||
version: data.extension.latestVersion || '1.0.0',
|
||||
version: targetVersion,
|
||||
type: data.extension.type,
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
||||
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${data.extension.name}" installed${warnings}`,
|
||||
});
|
||||
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
@@ -200,6 +212,12 @@ export default function MarketplacePreviewPage() {
|
||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||
const updateAvailable = data.installed
|
||||
&& !!data.installedVersion
|
||||
&& !!ext.latestVersion
|
||||
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
@@ -244,11 +262,22 @@ export default function MarketplacePreviewPage() {
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||
{data.installed && (
|
||||
<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">
|
||||
{data.installed && !updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
|
||||
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" /> Installed
|
||||
</span>
|
||||
)}
|
||||
{data.installed && updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
|
||||
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
|
||||
>
|
||||
<ArrowUpCircle className="w-3 h-3" /> Update available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
@@ -275,6 +304,17 @@ export default function MarketplacePreviewPage() {
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{data.installed ? (
|
||||
<>
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
|
||||
Update to v{ext.latestVersion}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
@@ -294,8 +334,11 @@ export default function MarketplacePreviewPage() {
|
||||
) : (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
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"
|
||||
disabled={installing || !!bundle.error || versionMismatch}
|
||||
title={versionMismatch
|
||||
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
|
||||
: undefined}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
Install
|
||||
@@ -310,6 +353,18 @@ export default function MarketplacePreviewPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versionMismatch && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Update Bulwark to install this extension</p>
|
||||
<p className="text-xs mt-0.5 opacity-90">
|
||||
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundle.error && (
|
||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import "./globals.css";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||
// next-intl's getLocale() returns the default locale here - emitting
|
||||
// <html lang="en"> on e.g. /de pages, which makes browsers offer to
|
||||
// "translate this page". Recover the active locale from the request pathname
|
||||
// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie /
|
||||
// Accept-Language) when the path carries no locale segment.
|
||||
async function resolveRequestLocale(): Promise<string> {
|
||||
const pathname = (await headers()).get("x-pathname") || "";
|
||||
const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s));
|
||||
return seg ?? (await getLocale());
|
||||
}
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -16,12 +30,30 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
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");
|
||||
// Localize the <head> description to match the UI language; a hardcoded
|
||||
// English description is another signal that makes Chrome offer to
|
||||
// "translate this page". Resolve the locale from the request path, since this
|
||||
// layout is above the [locale] segment (see resolveRequestLocale).
|
||||
const locale = await resolveRequestLocale();
|
||||
const t = await getTranslations({ locale });
|
||||
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
description: t("meta_description"),
|
||||
// A private webmail should not be indexed by search engines. This is opt-in
|
||||
// via Settings -> General; the default (false) emits noindex/nofollow.
|
||||
robots: configManager.get<boolean>("searchEngineIndexing", false)
|
||||
? { index: true, follow: true }
|
||||
: { index: false, follow: false },
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
@@ -30,7 +62,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
...(faviconUrl ? { icons: { icon: faviconUrl } } : {}),
|
||||
icons: { icon: withBasePath(faviconUrl) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +71,7 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const locale = await resolveRequestLocale();
|
||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
@@ -83,7 +115,6 @@ export default async function RootLayout({
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
<PWAInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
@@ -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")} />;
|
||||
}
|
||||
@@ -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")} />;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -101,6 +101,14 @@ export default function SetupWizardPage() {
|
||||
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
// Resolved in a post-mount effect, not at render, so the server-rendered
|
||||
// HTML (where window is absent) matches the client's first paint and
|
||||
// doesn't trip a hydration mismatch.
|
||||
const [insecureContext, setInsecureContext] = useState(false);
|
||||
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
|
||||
useEffect(() => {
|
||||
setInsecureContext(detectInsecureContext());
|
||||
}, []);
|
||||
|
||||
// ─── Initial status load ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -173,6 +181,10 @@ export default function SetupWizardPage() {
|
||||
}
|
||||
|
||||
// ─── Render shell ───────────────────────────────────────────────────────
|
||||
if (insecureContext && !insecureAcknowledged) {
|
||||
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
|
||||
}
|
||||
|
||||
if (bootstrapping) {
|
||||
return <CenteredCard><p className="text-muted-foreground">Loading…</p></CenteredCard>;
|
||||
}
|
||||
@@ -229,7 +241,7 @@ export default function SetupWizardPage() {
|
||||
} catch (e) {
|
||||
const msg = humanError(e);
|
||||
setError(msg);
|
||||
// Session expired mid-flow — kick the user back to the
|
||||
// Session expired mid-flow - kick the user back to the
|
||||
// welcome step so they can re-enter the token without
|
||||
// having to refresh.
|
||||
if (/wizard session required/i.test(msg)) {
|
||||
@@ -241,12 +253,12 @@ export default function SetupWizardPage() {
|
||||
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
|
||||
onFinish={() => {
|
||||
setCompleted(true);
|
||||
// Hard navigation after a beat — gives the user a moment
|
||||
// Hard navigation after a beat - gives the user a moment
|
||||
// to see the success screen and works around any router
|
||||
// edge cases that swallow client-side replaces after the
|
||||
// setupComplete flag flips.
|
||||
setTimeout(() => {
|
||||
window.location.assign('/admin/login');
|
||||
window.location.assign(`${getPathPrefix()}/admin/login`);
|
||||
}, 1500);
|
||||
}}
|
||||
/>
|
||||
@@ -328,13 +340,13 @@ function CompletedScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -347,6 +359,44 @@ function CompletedScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
|
||||
const httpsUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
|
||||
: '';
|
||||
return (
|
||||
<CenteredCard>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
|
||||
<ShieldAlert className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">You're running setup over plain HTTP</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
The setup token and admin password you enter here will travel in cleartext.
|
||||
Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
{httpsUrl && (
|
||||
<a
|
||||
href={httpsUrl}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Try HTTPS
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Continue over HTTP
|
||||
</button>
|
||||
</div>
|
||||
</CenteredCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AlreadyConfiguredScreen() {
|
||||
return (
|
||||
<CenteredCard>
|
||||
@@ -372,13 +422,13 @@ function AlreadyConfiguredScreen() {
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="/admin/login"
|
||||
href={`${getPathPrefix()}/admin/login`}
|
||||
className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
|
||||
>
|
||||
Sign in to admin dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Open webmail login
|
||||
@@ -536,7 +586,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
const data = await res.json();
|
||||
let entry: { status: ProbeStatus; message: string; url: string };
|
||||
if (data.status === 'jmap_detected') {
|
||||
entry = { status: 'jmap_detected', message: 'Connected — this looks like a JMAP server.', url: config.jmapServerUrl };
|
||||
entry = { status: 'jmap_detected', message: 'Connected - this looks like a JMAP server.', url: config.jmapServerUrl };
|
||||
} else if (data.status === 'reachable_no_jmap') {
|
||||
entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl };
|
||||
} else if (data.status === 'invalid_url') {
|
||||
@@ -618,7 +668,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
}
|
||||
if (!result) return;
|
||||
|
||||
// Hard-fail on these — no "are you sure" since they can't be right.
|
||||
// Hard-fail on these - no "are you sure" since they can't be right.
|
||||
if (result.status === 'invalid_url' || result.status === 'unreachable') {
|
||||
return;
|
||||
}
|
||||
@@ -685,7 +735,22 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
This URL uses plain HTTP.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
|
||||
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production — terminate TLS on the mail server or a reverse proxy in front of it.
|
||||
Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production - terminate TLS on the mail server or a reverse proxy in front of it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isPrivateOrLocalHostUrl(config.jmapServerUrl) && (
|
||||
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm font-medium text-foreground leading-relaxed">
|
||||
This URL only resolves locally.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
|
||||
Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. <code className="font-mono text-xs">https://mail.example.com</code>) in production.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -720,7 +785,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
onChange={(e) => setConfirmedNonJmap(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm text-foreground">I'm sure this is the right URL — continue anyway.</span>
|
||||
<span className="text-sm text-foreground">I'm sure this is the right URL - continue anyway.</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1105,7 +1170,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<StepHeader
|
||||
title="Branding"
|
||||
subtitle="All fields optional. Upload a file or paste a URL — defaults are used for anything you skip."
|
||||
subtitle="All fields optional. Upload a file or paste a URL - defaults are used for anything you skip."
|
||||
/>
|
||||
<Field label="Company / organization name">
|
||||
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
|
||||
@@ -1174,7 +1239,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
* One branding asset slot: shows a thumbnail preview if a value is set,
|
||||
* a file picker (uploads to /api/setup/branding), and a URL field for
|
||||
* operators who'd rather paste a link. Upload and URL are mutually
|
||||
* compatible — the URL field always reflects the persisted value.
|
||||
* compatible - the URL field always reflects the persisted value.
|
||||
*/
|
||||
function BrandingAsset({
|
||||
label,
|
||||
@@ -1264,7 +1329,7 @@ function BrandingAsset({
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
||||
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||
)}
|
||||
@@ -1522,7 +1587,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
|
||||
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
||||
{value || <span className="text-muted-foreground italic">—</span>}
|
||||
{value || <span className="text-muted-foreground italic">-</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1729,6 +1794,57 @@ function isInsecureHttpUrl(url: string): boolean {
|
||||
return /^http:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* The JMAP URL is called directly from the user's browser. A URL that only
|
||||
* resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS)
|
||||
* works during setup but breaks for any real user. Surface a soft warning
|
||||
* so the operator catches this before going live.
|
||||
*/
|
||||
function isPrivateOrLocalHostUrl(url: string): boolean {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return false;
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(trimmed).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Strip IPv6 brackets, if any.
|
||||
if (host.startsWith('[') && host.endsWith(']')) {
|
||||
host = host.slice(1, -1);
|
||||
}
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
||||
if (host.endsWith('.local')) return true;
|
||||
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;
|
||||
// IPv4 literal: only flag the well-known private/loopback/link-local ranges.
|
||||
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
||||
if (a === 10) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectInsecureContext(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (window.location.protocol !== 'http:') return false;
|
||||
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
|
||||
// Secure cookies even without TLS, so the wizard still works there. In dev
|
||||
// we still want to render the warning so we can preview it without spinning
|
||||
// up a non-loopback host.
|
||||
const host = window.location.hostname;
|
||||
const isLoopback =
|
||||
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||
if (isLoopback && process.env.NODE_ENV !== 'development') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function humanError(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (typeof e === 'string') return e;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
|
||||
// attribute in production excludes `allow-same-origin` for isolation). Any
|
||||
// asset request from this layout - bundled fonts, globals.css, etc. - is then
|
||||
// cross-origin from the "null" origin to the host origin and gets blocked
|
||||
// (fonts in particular require CORS). So this layout is intentionally minimal:
|
||||
// no font imports, no CSS imports. Plugins ship their own styles, and both the
|
||||
// plugin bundle and all host API calls travel over the postMessage RPC bridge,
|
||||
// so the sandbox never fetches same-origin assets itself.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Plugin sandbox',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
const IMAGE_FIELDS = [
|
||||
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
|
||||
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
|
||||
];
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'loginCompanyName', label: 'Company Name' },
|
||||
{ key: 'loginImprintUrl', label: 'Imprint URL' },
|
||||
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
|
||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||
];
|
||||
|
||||
export function BrandingTab() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
setEdits(prev => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
if (key in edits) return edits[key] as string;
|
||||
return (config[key]?.value as string) ?? '';
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
|
||||
setEdits({});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleUpload(slot: string, file: File) {
|
||||
setUploading(slot);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[slot]: { value: data.url, source: 'admin' },
|
||||
}));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
setUploading(null);
|
||||
}
|
||||
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[slot];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const isUploadedFile = (key: string): boolean => {
|
||||
const val = currentValue(key);
|
||||
return val.startsWith('/api/admin/branding/');
|
||||
};
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{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">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder="Enter URL or upload a file"
|
||||
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
ref={el => { fileInputRefs.current[field.key] = el; }}
|
||||
type="file"
|
||||
accept={field.accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(field.key, file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground">{field.label}</label>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
|
||||
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||
|
||||
Binary file not shown.
+162
-34
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
parseDomainBranding,
|
||||
type DomainBrandingEntry,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -21,45 +26,127 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/vnd.microsoft.icon',
|
||||
]);
|
||||
|
||||
type UploadSlot = BrandingOverrideKey;
|
||||
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
const VALID_SLOTS = new Set<UploadSlot>([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
|
||||
|
||||
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
|
||||
// because we'd need a real subdomain to serve the file from.
|
||||
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip directory traversal, keep only safe chars
|
||||
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function normalizeHost(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** Filename used to store a per-host uploaded asset. */
|
||||
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
|
||||
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
|
||||
}
|
||||
|
||||
/** True if the file belongs to the given host+slot (any extension). */
|
||||
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
|
||||
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
|
||||
return filename.startsWith(prefix);
|
||||
}
|
||||
|
||||
/** Merge a per-host update into the existing domainBranding array. */
|
||||
function mergeDomainEntry(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
patch: Partial<DomainBrandingEntry>,
|
||||
): DomainBrandingEntry[] {
|
||||
const next = current.slice();
|
||||
const idx = next.findIndex(e => e.host === host);
|
||||
if (idx === -1) {
|
||||
next.push({ host, ...patch });
|
||||
} else {
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove keys from a host's entry. If the entry has nothing left besides
|
||||
* `host`, drop it entirely. */
|
||||
function clearDomainKeys(
|
||||
current: DomainBrandingEntry[],
|
||||
host: string,
|
||||
keys: BrandingOverrideKey[],
|
||||
): DomainBrandingEntry[] {
|
||||
const idx = current.findIndex(e => e.host === host);
|
||||
if (idx === -1) return current;
|
||||
const entry = { ...current[idx] };
|
||||
for (const key of keys) delete (entry as Record<string, unknown>)[key];
|
||||
const next = current.slice();
|
||||
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
|
||||
next.splice(idx, 1);
|
||||
} else {
|
||||
next[idx] = entry;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
* - slot: which branding field this is for (e.g. "faviconUrl")
|
||||
* - host (optional): when set, the upload is stored against the
|
||||
* per-domain entry for that hostname instead of the global default.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const slot = formData.get('slot') as string | null;
|
||||
const rawHost = (formData.get('host') as string | null) ?? '';
|
||||
|
||||
if (!file || !slot) {
|
||||
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_SLOTS.has(slot)) {
|
||||
if (!VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||
}
|
||||
@@ -71,34 +158,51 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine extension from mime type
|
||||
const extMap: 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',
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||
const safeName = host
|
||||
? domainAssetName(host, slot as BrandingOverrideKey, ext)
|
||||
: sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
// Strip any prior asset for the same slot but a different extension so
|
||||
// the directory doesn't accumulate orphan files on re-upload.
|
||||
const dir = getBrandingDir();
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (f === safeName) continue;
|
||||
const isSame = host
|
||||
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
|
||||
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
|
||||
if (isSame) {
|
||||
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Update config to point to the served URL
|
||||
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
|
||||
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||
}
|
||||
|
||||
await auditLog('branding_upload', {
|
||||
slot,
|
||||
host: host || undefined,
|
||||
filename: safeName,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
}, ip);
|
||||
|
||||
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||
} catch (error) {
|
||||
@@ -110,36 +214,60 @@ export async function POST(request: NextRequest) {
|
||||
/**
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
* Expects JSON body: { slot: string, host?: string }
|
||||
*
|
||||
* When `host` is provided, only the per-domain asset for that host+slot is
|
||||
* removed (and the override in `domainBranding[host][slot]` is cleared).
|
||||
* Otherwise the global asset and config override are removed.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const { slot } = await request.json();
|
||||
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
|
||||
const slot = body.slot;
|
||||
const rawHost = body.host ?? '';
|
||||
|
||||
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||
if (!slot || !VALID_SLOTS.has(slot as UploadSlot)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find and remove matching files for this slot
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
const host = rawHost ? normalizeHost(rawHost) : '';
|
||||
if (host && !EXACT_HOST_RE.test(host)) {
|
||||
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const dir = getBrandingDir();
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
if (host) {
|
||||
const allFiles = await readdir(dir).catch(() => [] as string[]);
|
||||
for (const f of allFiles) {
|
||||
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
|
||||
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const ext of POSSIBLE_EXTS) {
|
||||
const filePath = path.join(dir, `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the config override so it falls back to default/env
|
||||
await configManager.ensureLoaded();
|
||||
await configManager.removeAdminOverride(slot);
|
||||
if (host) {
|
||||
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
|
||||
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
|
||||
await configManager.setAdminConfig({ domainBranding: next });
|
||||
} else {
|
||||
await configManager.removeAdminOverride(slot);
|
||||
}
|
||||
|
||||
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
|
||||
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -2,22 +2,46 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
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 { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||
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)
|
||||
*
|
||||
* 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 {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
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' },
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -31,7 +55,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -65,6 +89,25 @@ export async function PATCH(request: NextRequest) {
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Normalize domainBranding: drop entries with an invalid/missing host or
|
||||
// duplicate hosts before persisting. Each entry's branding field strings
|
||||
// are passed through unchanged (URL/string content is the operator's
|
||||
// responsibility, same as the flat branding fields).
|
||||
if ('domainBranding' in updates) {
|
||||
const incoming = updates.domainBranding;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseDomainBranding(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.domainBranding = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
@@ -86,7 +129,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -7,8 +7,12 @@ import {
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import JSZip from 'jszip';
|
||||
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;
|
||||
|
||||
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
|
||||
// 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(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
@@ -63,7 +68,7 @@ export async function GET(
|
||||
try {
|
||||
const bundleUrl = new URL(
|
||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||
DIRECTORY_URL,
|
||||
directoryUrl,
|
||||
);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
@@ -144,14 +149,16 @@ export async function GET(
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
const installed = type === 'theme'
|
||||
? themeRegistry.themes.some((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||
const installedEntry = type === 'theme'
|
||||
? themeRegistry.themes.find((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.find((p) => p.id === slug);
|
||||
const installed = installedEntry !== undefined;
|
||||
const installedVersion = installedEntry?.version ?? null;
|
||||
|
||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||
const screenshots = Array.isArray(extension.screenshots)
|
||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||
altText: s.altText ?? null,
|
||||
}))
|
||||
: [];
|
||||
@@ -170,7 +177,7 @@ export async function GET(
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -206,6 +213,7 @@ export async function GET(
|
||||
error: bundleError,
|
||||
},
|
||||
installed,
|
||||
installedVersion,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
|
||||
import {
|
||||
savePlugin,
|
||||
saveTheme,
|
||||
getPlugin,
|
||||
getTheme,
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
type ServerPlugin,
|
||||
@@ -13,13 +15,18 @@ import {
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
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
|
||||
@@ -27,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
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
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
@@ -58,23 +66,32 @@ export async function GET(request: NextRequest) {
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
|
||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||
const installedPluginVersions = new Map(
|
||||
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}`, DIRECTORY_URL).toString()
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
if (data.data) {
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||
...ext,
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: ext.type === 'theme'
|
||||
? installedThemes.has(ext.slug as string)
|
||||
: installedPlugins.has(ext.slug as string),
|
||||
}));
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => {
|
||||
const slug = ext.slug as string;
|
||||
const installedVersion = ext.type === 'theme'
|
||||
? installedThemeVersions.get(slug) ?? null
|
||||
: installedPluginVersions.get(slug) ?? null;
|
||||
return {
|
||||
...ext,
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: installedVersion !== null,
|
||||
installedVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
@@ -92,7 +109,7 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -107,7 +124,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 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(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
@@ -163,6 +181,18 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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') {
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
@@ -181,22 +211,43 @@ export async function POST(request: NextRequest) {
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
|
||||
const existingTheme = await getTheme(resolvedId);
|
||||
const isUpdate = existingTheme !== null;
|
||||
|
||||
const theme: ServerTheme = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
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',
|
||||
description: (manifest.description as string) || '',
|
||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
enabled: existingTheme?.enabled ?? true,
|
||||
...(existingTheme?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingTheme.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingTheme?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
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 {
|
||||
// Plugin installation
|
||||
// Read entrypoint JS
|
||||
@@ -266,31 +317,73 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
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',
|
||||
description: (manifest.description as string) || '',
|
||||
type: (manifest.type as string) || 'hook',
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
enabled: existingPlugin?.enabled ?? true,
|
||||
...(existingPlugin?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingPlugin.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingPlugin?.installedAt ?? 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
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, 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) {
|
||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
|
||||
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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
|
||||
@@ -25,14 +30,15 @@ export async function GET(
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readDevBundle(devEntry);
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
},
|
||||
});
|
||||
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);
|
||||
@@ -59,6 +65,9 @@ export async function GET(
|
||||
};
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminAuth = await requireAdminAuth();
|
||||
const adminAuth = await requireAdminAuth(request);
|
||||
const isAdmin = !('error' in adminAuth);
|
||||
|
||||
if (!isAdmin) {
|
||||
@@ -51,13 +51,18 @@ export async function GET(
|
||||
|
||||
const config = await getPluginConfig(id);
|
||||
|
||||
let response: Record<string, unknown> = config;
|
||||
if (!isAdmin && plugin.configSchema) {
|
||||
let response: Record<string, unknown>;
|
||||
if (isAdmin) {
|
||||
response = config;
|
||||
} else {
|
||||
response = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = plugin.configSchema[key];
|
||||
if (field?.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
const schema = plugin.configSchema;
|
||||
if (schema) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = schema[key];
|
||||
if (!field || field.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +85,7 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
@@ -110,6 +115,13 @@ export async function PUT(
|
||||
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);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
@@ -127,7 +139,7 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const [registry, devEntries] = await Promise.all([
|
||||
@@ -63,7 +64,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
@@ -190,19 +192,25 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||
: {}),
|
||||
...(manifest.locales && typeof manifest.locales === 'object'
|
||||
? { locales: manifest.locales as ServerPlugin['locales'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, 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 });
|
||||
} catch (error) {
|
||||
@@ -217,7 +225,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -235,9 +243,18 @@ export async function PATCH(request: NextRequest) {
|
||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||
|
||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||
const updated = await updatePluginMeta(id, updates);
|
||||
let updated = await updatePluginMeta(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
// 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 });
|
||||
}
|
||||
updated = { ...devEntry.plugin, ...updates };
|
||||
}
|
||||
|
||||
// Enable/disable changes the set of plugins contributing frame origins.
|
||||
@@ -259,7 +276,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
* Returns current consent + endpoint + next/last send + a live preview
|
||||
* of exactly what the next heartbeat would contain.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
@@ -61,7 +61,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getThemeRegistry();
|
||||
@@ -36,7 +36,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
* GET /api/admin/version
|
||||
* Returns the cached update status, last check times, and effective config.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const state = await loadState();
|
||||
@@ -47,7 +47,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(req);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||
|
||||
@@ -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: '/' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
|
||||
/**
|
||||
* Same-origin OAuth metadata (discovery) proxy.
|
||||
*
|
||||
* The login page needs the authorization_endpoint to build the PKCE authorize
|
||||
* URL in the browser. Discovering it directly from the browser means a
|
||||
* cross-origin fetch to the IdP's /.well-known/* documents, which is subject
|
||||
* to CORS: providers like Authentik serve those documents without an
|
||||
* Access-Control-Allow-Origin header, so the browser blocks the response and
|
||||
* discovery fails (issue #382). Performing discovery here - server to server,
|
||||
* where CORS does not apply - and handing the result back as a same-origin
|
||||
* response sidesteps the problem entirely.
|
||||
*
|
||||
* The discovery URL is resolved from admin config (via server_id), never from
|
||||
* client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs
|
||||
* in the discovered document are still gated by the SSRF validator inside
|
||||
* discoverOAuth. The returned fields are public well-known metadata.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
await configManager.ensureLoaded();
|
||||
const serverId = request.nextUrl.searchParams.get('server_id');
|
||||
|
||||
let discoveryUrl: string;
|
||||
try {
|
||||
({ discoveryUrl } = getRequiredConfig(serverId));
|
||||
} catch {
|
||||
// OAuth not configured for this server - surface as "no metadata" rather
|
||||
// than a 500 so the login page just hides the SSO button.
|
||||
return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await getMetadata(serverId);
|
||||
if (!metadata?.authorization_endpoint || !metadata.token_endpoint) {
|
||||
logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl });
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(metadata, {
|
||||
// Mirror the in-process discovery cache TTL so repeated login-page loads
|
||||
// hit the CDN/browser cache instead of re-running discovery.
|
||||
headers: { 'Cache-Control': 'private, max-age=600' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('OAuth metadata discovery error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,12 @@ 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 = {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
|
||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
const token = encryptSession(normalizedServerUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
username,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
getRequiredConfig,
|
||||
getTokenEndpoint,
|
||||
} from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
@@ -56,6 +60,10 @@ export async function POST(request: NextRequest) {
|
||||
const codeVerifier = pending.code_verifier 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) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
@@ -65,21 +73,46 @@ export async function POST(request: NextRequest) {
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
// 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) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
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
|
||||
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({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
|
||||
@@ -3,27 +3,49 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||
// this prefix may flow through the mobile handoff path; without the guard
|
||||
// the SSO complete route would be coerced into returning tokens to whatever
|
||||
// caller-controlled URL the attacker chose.
|
||||
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!hasSessionSecret()) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale, server_id: bodyServerId } = 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') {
|
||||
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
|
||||
@@ -39,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
@@ -52,12 +74,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 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 = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
@@ -73,7 +100,7 @@ export async function POST(request: NextRequest) {
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
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('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, 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 { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
@@ -57,15 +57,22 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const slot = getSlot(request, bodySlot);
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
|
||||
// just authenticated to JMAP with these credentials, and the cookie we
|
||||
// write here is only ever consumed for requests on behalf of this same
|
||||
// user — a bogus auth header would just yield 401s downstream, not
|
||||
// privilege escalation. For untrusted custom endpoints we still verify
|
||||
// upstream as before.
|
||||
const normalizedServerUrl = upstreamTrusted
|
||||
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
// 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, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -52,9 +53,13 @@ async function tryTokenRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl);
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
@@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
|
||||
+67
-35
@@ -1,8 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -12,48 +19,73 @@ import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
* post-build configuration for Docker deployments.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Admin dashboard overrides (data/admin/config.json)
|
||||
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 4. Default values
|
||||
* 1. Per-domain branding override (admin-configured, matched on request host)
|
||||
* 2. Admin dashboard overrides (data/admin/config.json)
|
||||
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
|
||||
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
|
||||
* 5. Default values
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
logger.debug('Config requested');
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const host = pickRequestHost(request);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
|
||||
// Per-domain override wins over the global value, but only when the
|
||||
// entry explicitly sets that key. Otherwise we fall through to the
|
||||
// global admin/env/default chain.
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === 'string' && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
|
||||
|
||||
return NextResponse.json({
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', 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),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
appName,
|
||||
jmapServerUrl,
|
||||
oauthEnabled,
|
||||
oauthOnly,
|
||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||
oauthScopes: getOauthScopes(),
|
||||
rememberMeEnabled: hasSessionSecret(),
|
||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', 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),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
},
|
||||
{
|
||||
// Branding varies by host, so any cache between us and the browser
|
||||
// must key its entry by the host headers we consulted.
|
||||
headers: { Vary: 'Host, X-Forwarded-Host' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
*/
|
||||
|
||||
const ACCOUNT_ID = 'dev-account-001';
|
||||
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
|
||||
const emailCreationIds = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mailboxes
|
||||
@@ -106,15 +108,15 @@ const emails: MockEmail[] = [
|
||||
// =====================================================================
|
||||
{
|
||||
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: [],
|
||||
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,
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
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 +199,7 @@ const emails: MockEmail[] = [
|
||||
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' }],
|
||||
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',
|
||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||
hasAttachment: false,
|
||||
@@ -367,7 +369,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
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' }],
|
||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||
subject: 'Re: Sprint planning - next week priorities',
|
||||
@@ -471,7 +473,7 @@ const emails: MockEmail[] = [
|
||||
{
|
||||
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' }],
|
||||
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
|
||||
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
|
||||
subject: 'Design review feedback',
|
||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||
hasAttachment: false,
|
||||
@@ -485,7 +487,7 @@ const emails: MockEmail[] = [
|
||||
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' }],
|
||||
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',
|
||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||
hasAttachment: false,
|
||||
@@ -639,7 +641,7 @@ const emails: MockEmail[] = [
|
||||
},
|
||||
{
|
||||
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: [],
|
||||
subject: 'Conference talk accepted!',
|
||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||
@@ -721,15 +723,26 @@ const emails: MockEmail[] = [
|
||||
// Identities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IDENTITIES = [
|
||||
type MockIdentity = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: Array<{ name?: string; email: string }> | null;
|
||||
bcc: Array<{ name?: string; email: string }> | null;
|
||||
textSignature: string | null;
|
||||
htmlSignature: string | null;
|
||||
mayDelete: boolean;
|
||||
};
|
||||
|
||||
const IDENTITIES: MockIdentity[] = [
|
||||
{
|
||||
id: 'identity-001',
|
||||
name: 'Dev User',
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
@@ -743,6 +756,12 @@ const addressBooks = [
|
||||
{ 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 = [
|
||||
// --- Personal address book ---
|
||||
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||
@@ -752,6 +771,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'EuroTech GmbH' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||
@@ -760,6 +780,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||
@@ -768,6 +789,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||
@@ -775,6 +797,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+31 20 555 0142' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||
@@ -783,6 +806,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||
@@ -791,6 +815,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||
@@ -798,6 +823,7 @@ const contacts = [
|
||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||
@@ -806,6 +832,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'BergLabs' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||
@@ -814,6 +841,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||
@@ -822,6 +850,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||
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.' } },
|
||||
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||
},
|
||||
// --- Work address book ---
|
||||
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||
@@ -831,6 +860,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||
@@ -839,6 +869,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||
@@ -847,6 +878,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
||||
@@ -855,6 +887,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'García Design Studio' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||
@@ -863,6 +896,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Digitaal BV' } },
|
||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||
@@ -871,6 +905,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||
@@ -879,6 +914,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||
@@ -887,6 +923,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Ferreira Media' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||
@@ -895,6 +932,7 @@ const contacts = [
|
||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||
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.' } },
|
||||
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',
|
||||
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
||||
@@ -904,6 +942,7 @@ const contacts = [
|
||||
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
||||
nicknames: { n1: { name: 'Anni' } },
|
||||
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 ---
|
||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||
@@ -976,7 +1015,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
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'),
|
||||
},
|
||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||
@@ -986,7 +1025,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
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'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
},
|
||||
@@ -1024,7 +1063,7 @@ const calendarEvents = [
|
||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||
participants: {
|
||||
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'),
|
||||
},
|
||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||
@@ -1054,7 +1093,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
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'),
|
||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
@@ -1066,7 +1105,7 @@ const calendarEvents = [
|
||||
participants: {
|
||||
p1: participant('Dev User', 'dev@localhost'),
|
||||
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', {
|
||||
@@ -1084,7 +1123,7 @@ const calendarEvents = [
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.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', {
|
||||
@@ -1093,7 +1132,7 @@ const calendarEvents = [
|
||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||
p2: participant('Lars Johansson', 'lars.johansson@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', {
|
||||
@@ -1109,7 +1148,7 @@ const calendarEvents = [
|
||||
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.',
|
||||
participants: {
|
||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
||||
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||
p2: participant('Dev User', 'dev@localhost'),
|
||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||
@@ -1192,7 +1231,7 @@ const calendarEvents = [
|
||||
}),
|
||||
|
||||
// ===== 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,
|
||||
recurrence: [{ frequency: 'yearly' }],
|
||||
description: 'Don\'t forget to bring Kuchen!',
|
||||
@@ -1220,7 +1259,7 @@ const calendarEvents = [
|
||||
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
||||
participants: {
|
||||
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'),
|
||||
},
|
||||
}),
|
||||
@@ -1507,6 +1546,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
|
||||
bodyValues: {},
|
||||
};
|
||||
emails.unshift(newEmail);
|
||||
emailCreationIds.set(key, newId);
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
@@ -1533,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
|
||||
function handleIdentitySet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
const create = args.create as Record<string, unknown> | undefined;
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
|
||||
const create = args.create as Record<string, Record<string, unknown>> | undefined;
|
||||
if (create) {
|
||||
for (const key of Object.keys(create)) {
|
||||
created[key] = { id: `identity-new-${Date.now()}-${key}` };
|
||||
for (const [key, data] of Object.entries(create)) {
|
||||
const newId = `identity-${Date.now()}-${key}`;
|
||||
IDENTITIES.push({
|
||||
id: newId,
|
||||
name: (data.name as string) || '',
|
||||
email: (data.email as string) || '',
|
||||
replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null,
|
||||
bcc: (data.bcc as MockIdentity['bcc']) ?? null,
|
||||
textSignature: (data.textSignature as string | null) ?? null,
|
||||
htmlSignature: (data.htmlSignature as string | null) ?? null,
|
||||
mayDelete: true,
|
||||
});
|
||||
created[key] = { id: newId };
|
||||
}
|
||||
}
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId];
|
||||
|
||||
const update = args.update as Record<string, Record<string, unknown>> | undefined;
|
||||
if (update) {
|
||||
for (const [id, changes] of Object.entries(update)) {
|
||||
const identity = IDENTITIES.find((i) => i.id === id);
|
||||
if (identity) {
|
||||
// Email is immutable per the identity form, so it's never in `changes`.
|
||||
if (changes.name !== undefined) identity.name = changes.name as string;
|
||||
if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo'];
|
||||
if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc'];
|
||||
if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null;
|
||||
if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = args.destroy as string[] | undefined;
|
||||
if (destroy) {
|
||||
for (const id of destroy) {
|
||||
const idx = IDENTITIES.findIndex((i) => i.id === id);
|
||||
if (idx !== -1) {
|
||||
IDENTITIES.splice(idx, 1);
|
||||
destroyed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId];
|
||||
}
|
||||
|
||||
function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1549,8 +1631,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
|
||||
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
|
||||
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
|
||||
const created: Record<string, { id: string; sendAt?: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
|
||||
if (create) {
|
||||
for (const [key, value] of Object.entries(create)) {
|
||||
const id = `submission-${Date.now()}-${key}`;
|
||||
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
|
||||
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
|
||||
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
|
||||
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
|
||||
? Date.now() + holdForSeconds * 1000
|
||||
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
|
||||
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
|
||||
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
|
||||
if (delayedUntil && value.emailId && value.identityId) {
|
||||
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
|
||||
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
|
||||
if (update) {
|
||||
for (const [id, patch] of Object.entries(update)) {
|
||||
const submission = scheduledSubmissions.find(s => s.id === id);
|
||||
if (submission && patch.undoStatus) {
|
||||
submission.undoStatus = patch.undoStatus;
|
||||
updated[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
|
||||
const position = Number(args.position || 0);
|
||||
const limit = Number(args.limit || 50);
|
||||
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
|
||||
}
|
||||
|
||||
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
|
||||
const ids = args.ids as string[] | undefined;
|
||||
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
|
||||
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
|
||||
}
|
||||
|
||||
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
|
||||
@@ -1613,6 +1738,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
|
||||
'Identity/get': handleIdentityGet,
|
||||
'Identity/set': handleIdentitySet,
|
||||
'EmailSubmission/set': handleEmailSubmissionSet,
|
||||
'EmailSubmission/query': handleEmailSubmissionQuery,
|
||||
'EmailSubmission/get': handleEmailSubmissionGet,
|
||||
'Quota/get': handleQuotaGet,
|
||||
'VacationResponse/get': handleVacationResponseGet,
|
||||
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
|
||||
@@ -1750,7 +1877,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isReadOnly: false,
|
||||
accountCapabilities: {
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:quota': {},
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
|
||||
+17
-12
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
|
||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||
|
||||
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
|
||||
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
|
||||
// without spamming the DevTools console with red 404 errors. Avatar.tsx
|
||||
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
|
||||
const TRANSPARENT_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MISSING_FAVICON_HEADERS = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400', // 1 day
|
||||
'X-Bulwark-Favicon': 'missing',
|
||||
};
|
||||
|
||||
// Strict domain validation to prevent SSRF
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
|
||||
// Check negative cache (domains known to have no favicon)
|
||||
const neg = negativeCache.get(normalizedDomain);
|
||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Check cache
|
||||
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!upstream.ok) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
|
||||
if (data.byteLength < 10) {
|
||||
evictNegativeOldest();
|
||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||
return new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||
});
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
|
||||
@@ -4,6 +4,26 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
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) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -18,7 +38,14 @@ export async function POST(request: NextRequest) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -27,7 +54,8 @@ export async function POST(request: NextRequest) {
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
let currentUrl = url;
|
||||
let currentUrl = cleanUrl;
|
||||
const originalOrigin = new URL(cleanUrl).origin;
|
||||
let response: Response | undefined;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'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: {
|
||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
||||
},
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
|
||||
|
||||
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
@@ -34,7 +39,11 @@ export async function GET() {
|
||||
type: p.type,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
forceEnabled: p.forceEnabled || false,
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
// 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,
|
||||
@@ -43,9 +52,14 @@ export async function GET() {
|
||||
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,
|
||||
// Plugin-declared i18n tables, so the sandbox can localize plugin
|
||||
// strings via api.i18n.t().
|
||||
locales: p.locales,
|
||||
}));
|
||||
|
||||
// Only serve enabled themes
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 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
|
||||
*
|
||||
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
// 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);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
target = await resolveDefaultTarget(creds);
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||
}
|
||||
authHeader = creds.authHeader;
|
||||
}
|
||||
|
||||
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: creds.authHeader },
|
||||
});
|
||||
if (!sessionRes.ok) {
|
||||
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
|
||||
}
|
||||
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 NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
|
||||
}
|
||||
const { apiUrl, accountId } = target;
|
||||
|
||||
const inboxRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
|
||||
const jmapRes = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: creds.authHeader,
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
|
||||
@@ -2,11 +2,19 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
@@ -16,13 +24,21 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
@@ -32,7 +48,18 @@ export async function GET(
|
||||
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 host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const iconUrl =
|
||||
domainOverrides.pwaIconUrl ||
|
||||
domainOverrides.faviconUrl ||
|
||||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||
if (!iconUrl) {
|
||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||
}
|
||||
@@ -40,11 +67,14 @@ export async function GET(
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
@@ -56,7 +86,7 @@ export async function GET(
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
|
||||
/**
|
||||
* Variant → target output size + admin config key.
|
||||
* Matches the sizes declared in app/manifest.ts so the rendered PNG fits
|
||||
* the slot the manifest tells the browser about.
|
||||
*/
|
||||
const VARIANTS = {
|
||||
mobile: { width: 540, height: 720, configKey: 'pwaScreenshotMobileUrl' as const },
|
||||
desktop: { width: 1280, height: 720, configKey: 'pwaScreenshotDesktopUrl' as const },
|
||||
} as const;
|
||||
|
||||
type Variant = keyof typeof VARIANTS;
|
||||
|
||||
// Cache resized images keyed by (variant, source URL).
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA screenshot: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ variant: string }> },
|
||||
) {
|
||||
const { variant: variantParam } = await params;
|
||||
if (!(variantParam in VARIANTS)) {
|
||||
return new NextResponse('Invalid variant. Allowed: mobile, desktop', { status: 400 });
|
||||
}
|
||||
const { width, height, configKey } = VARIANTS[variantParam as Variant];
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
const host = pickRequestHost(req);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
|
||||
);
|
||||
const sources = configManager.getAllWithSources();
|
||||
const sourceEntry = sources[configKey];
|
||||
const screenshotUrl =
|
||||
domainOverrides[configKey] ||
|
||||
(sourceEntry?.source !== 'default' ? (sourceEntry?.value as string | undefined) : undefined);
|
||||
if (!screenshotUrl) {
|
||||
return new NextResponse('No PWA screenshot configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
Vary: 'Host, X-Forwarded-Host',
|
||||
};
|
||||
const cacheKey = `${variantParam}|${screenshotUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(screenshotUrl);
|
||||
// 'cover' fills the target box without letterboxing - screenshots benefit
|
||||
// more from cropping than from a transparent frame around them. Users get
|
||||
// a hint about the recommended aspect ratio in the admin UI.
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(width, height, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA screenshot:', err);
|
||||
return new NextResponse('Failed to generate screenshot', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,13 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
'loginLogoDarkUrl',
|
||||
'pwaScreenshotMobileUrl',
|
||||
'pwaScreenshotDesktopUrl',
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
@@ -47,14 +50,14 @@ function sanitizeFilename(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/setup/branding — wizard branding upload.
|
||||
* 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.)
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -126,7 +129,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/setup/branding — remove an uploaded asset and clear the
|
||||
* 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 }
|
||||
|
||||
@@ -58,13 +58,16 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Provision the admin account. Aborts cleanly if one already exists
|
||||
// (defence in depth - should be impossible in bootstrap state).
|
||||
const created = await setInitialAdminPassword(adminPassword);
|
||||
// 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: 'Admin account already exists; cannot finish setup again' },
|
||||
{ status: 409 },
|
||||
{ error: 'Failed to write admin credentials' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const attrs = buildSessionCookieAttributes();
|
||||
const attrs = buildSessionCookieAttributes(request);
|
||||
response.cookies.set(attrs.name, submitted, {
|
||||
httpOnly: attrs.httpOnly,
|
||||
sameSite: attrs.sameSite,
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
||||
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
||||
// provider } here via api.http.post; we forward to a free translation backend
|
||||
// and return { translatedText, detectedSource } in a stable shape.
|
||||
//
|
||||
// Two providers:
|
||||
// - "mymemory" — public MyMemory API, no configuration required. Its
|
||||
// langpair needs an explicit source language, so when the
|
||||
// plugin asks for "auto" we detect it locally first.
|
||||
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
|
||||
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
|
||||
// source auto-detection.
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
// Long bodies are split into ~480-char chunks for MyMemory and translated
|
||||
// sequentially, so allow enough headroom for ~10 round-trips.
|
||||
const TIMEOUT_MS = 25000;
|
||||
|
||||
type Provider = 'mymemory' | 'libretranslate';
|
||||
|
||||
interface TranslateBody {
|
||||
text?: unknown;
|
||||
target?: unknown;
|
||||
source?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
|
||||
interface TranslateResult {
|
||||
translatedText: string;
|
||||
detectedSource?: string;
|
||||
}
|
||||
|
||||
// ─── Lightweight language detection ───────────────────────────
|
||||
//
|
||||
// MyMemory has no auto-detect, so we infer a source language from the text.
|
||||
// Non-Latin scripts are decided by Unicode range; Latin-script European
|
||||
// languages are scored by stop-word frequency. Detection only needs to be good
|
||||
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
|
||||
// already in the target language.
|
||||
|
||||
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
|
||||
[/[-ヿ]/, 'ja'], // Hiragana / Katakana
|
||||
[/[가-]/, 'ko'], // Hangul
|
||||
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
|
||||
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
|
||||
[/[Ͱ-Ͽ]/, 'el'], // Greek
|
||||
[/[-ۿ]/, 'ar'], // Arabic
|
||||
[/[-]/, 'he'], // Hebrew
|
||||
[/[ऀ-ॿ]/, 'hi'], // Devanagari
|
||||
];
|
||||
|
||||
// Distinctive stop words per Latin-script language from the manifest's option
|
||||
// list. Kept small and high-signal to avoid cross-language collisions.
|
||||
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
|
||||
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
|
||||
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
|
||||
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
|
||||
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
|
||||
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
|
||||
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
|
||||
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
|
||||
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
|
||||
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
|
||||
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
|
||||
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
|
||||
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
|
||||
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
|
||||
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
|
||||
};
|
||||
|
||||
function detectLanguage(text: string): string {
|
||||
const sample = text.slice(0, 1000);
|
||||
for (const [range, lang] of SCRIPT_RANGES) {
|
||||
if (range.test(sample)) {
|
||||
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
|
||||
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
|
||||
if (words.length === 0) return 'en';
|
||||
const counts: Record<string, number> = {};
|
||||
const wordSet = new Set(words);
|
||||
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
|
||||
let score = 0;
|
||||
for (const stop of stops) if (wordSet.has(stop)) score += 1;
|
||||
counts[lang] = score;
|
||||
}
|
||||
let best = 'en';
|
||||
let bestScore = -1;
|
||||
for (const [lang, score] of Object.entries(counts)) {
|
||||
if (score > bestScore) {
|
||||
best = lang;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestScore > 0 ? best : 'en';
|
||||
}
|
||||
|
||||
function baseLang(code: string): string {
|
||||
return String(code || '').toLowerCase().split('-')[0];
|
||||
}
|
||||
|
||||
// ─── Chunking ─────────────────────────────────────────────────
|
||||
//
|
||||
// MyMemory's free endpoint caps each request's `q` at 500 characters and
|
||||
// silently returns only the translated prefix beyond that — which is why long
|
||||
// emails came back truncated ("…about Sel…"). We split the text into
|
||||
// line-aware chunks under the limit, translate each, then rejoin so the whole
|
||||
// body is covered.
|
||||
|
||||
const MYMEMORY_CHUNK = 480;
|
||||
|
||||
function chunkText(text: string, max: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let cur = '';
|
||||
const flush = () => {
|
||||
if (cur) {
|
||||
chunks.push(cur);
|
||||
cur = '';
|
||||
}
|
||||
};
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.length > max) {
|
||||
flush();
|
||||
// A single over-long line (e.g. a long URL list): split on words, and
|
||||
// hard-cut any word that is itself longer than the limit.
|
||||
let seg = '';
|
||||
for (const word of line.split(' ')) {
|
||||
const piece = seg ? seg + ' ' + word : word;
|
||||
if (piece.length > max) {
|
||||
if (seg) {
|
||||
chunks.push(seg);
|
||||
seg = '';
|
||||
}
|
||||
if (word.length > max) {
|
||||
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
|
||||
} else {
|
||||
seg = word;
|
||||
}
|
||||
} else {
|
||||
seg = piece;
|
||||
}
|
||||
}
|
||||
if (seg) chunks.push(seg);
|
||||
continue;
|
||||
}
|
||||
if (cur && cur.length + 1 + line.length > max) flush();
|
||||
cur = cur ? cur + '\n' + line : line;
|
||||
}
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Providers ────────────────────────────────────────────────
|
||||
|
||||
async function mymemoryRequest(
|
||||
q: string,
|
||||
langpair: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
const url = new URL('https://api.mymemory.translated.net/get');
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('langpair', langpair);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(`MyMemory returned ${res.status}`);
|
||||
}
|
||||
const status = Number(data.responseStatus);
|
||||
if (status && status !== 200) {
|
||||
throw new Error(data.responseDetails || `MyMemory error ${status}`);
|
||||
}
|
||||
const translatedText = data.responseData?.translatedText || '';
|
||||
if (!translatedText) {
|
||||
throw new Error('MyMemory returned no translation');
|
||||
}
|
||||
return translatedText;
|
||||
}
|
||||
|
||||
async function translateMyMemory(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
|
||||
const tgt = baseLang(target);
|
||||
// Nothing to do if already in the target language; the plugin skips display.
|
||||
if (detected === tgt) {
|
||||
return { translatedText: text, detectedSource: detected };
|
||||
}
|
||||
const langpair = `${detected}|${tgt}`;
|
||||
const chunks = chunkText(text, MYMEMORY_CHUNK);
|
||||
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
|
||||
// usually one or two chunks.
|
||||
const translated: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
translated.push(await mymemoryRequest(chunk, langpair, signal));
|
||||
}
|
||||
return { translatedText: translated.join('\n'), detectedSource: detected };
|
||||
}
|
||||
|
||||
async function translateLibre(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const endpoint = process.env.LIBRETRANSLATE_URL;
|
||||
if (!endpoint) {
|
||||
throw new Error('LibreTranslate is not configured on this server');
|
||||
}
|
||||
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
|
||||
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
q: text,
|
||||
source: source || 'auto',
|
||||
target: baseLang(target),
|
||||
format: 'text',
|
||||
...(apiKey ? { api_key: apiKey } : {}),
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
|
||||
}
|
||||
if (!data.translatedText) {
|
||||
throw new Error(data.error || 'LibreTranslate returned no translation');
|
||||
}
|
||||
return {
|
||||
translatedText: data.translatedText,
|
||||
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Route ────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: TranslateBody;
|
||||
try {
|
||||
body = (await request.json()) as TranslateBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
||||
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
||||
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const result =
|
||||
provider === 'libretranslate'
|
||||
? await translateLibre(text, source, target, controller.signal)
|
||||
: await translateMyMemory(text, source, target, controller.signal);
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Translation failed';
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -70,7 +70,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-border: #262626;
|
||||
--color-border: rgba(128, 128, 128, 0.3);
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-background: #0a0a0a;
|
||||
@@ -171,8 +171,15 @@ body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
"Noto Sans Thai",
|
||||
"Leelawadee UI",
|
||||
Tahoma,
|
||||
sans-serif;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
@@ -233,6 +240,13 @@ body {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-text {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.email-content-text a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="isolation:isolate" viewBox="0 0 1000 1000">
|
||||
<defs>
|
||||
<clipPath id="_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV"><rect width="1000" height="1000"/></clipPath>
|
||||
<style>
|
||||
.icon-bg { fill: #ffffff; }
|
||||
.icon-mark { fill: rgb(219,45,84); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-bg { fill: #18181b; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g clip-path="url(#_clipPath_ONeeZd4dujNSzmUupv5CE8R64LUE9BqV)">
|
||||
<rect width="1000" height="1000" class="icon-bg"/>
|
||||
<path d=" M 489.315 575.068 L 225.342 338.071 C 222.394 335.424 220 330.058 220 326.095 L 220 297.377 C 220 293.415 223.135 289.474 226.996 288.583 L 320.697 266.96 C 324.558 266.069 327.692 268.563 327.692 272.525 L 327.692 331.61 L 406.851 313.338 C 410.712 312.446 413.846 308.506 413.846 304.543 L 413.846 252.643 C 413.846 248.681 416.981 244.741 420.842 243.85 L 493.004 227.197 C 496.865 226.306 503.135 226.306 506.996 227.197 L 579.158 243.85 C 583.019 244.741 586.154 248.681 586.154 252.643 L 586.154 304.543 C 586.154 308.506 589.288 312.446 593.149 313.338 L 672.308 331.61 L 672.308 272.525 C 672.308 268.563 675.442 266.069 679.303 266.96 L 773.004 288.583 C 776.865 289.474 780 293.415 780 297.377 L 780 326.095 C 780 330.058 777.606 335.424 774.658 338.071 L 510.685 575.068 C 504.788 580.362 495.212 580.362 489.315 575.068 Z " class="icon-mark"/>
|
||||
<path d=" M 780 429.762 L 780 470.138 C 780 474.101 777.725 479.593 774.923 482.394 L 742 515.318 C 739.198 518.12 736.923 523.612 736.923 527.574 L 736.923 649.625 C 736.922 672.529 730.827 692.394 719.048 710.431 L 599.991 591.373 L 780 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 220 429.762 L 220 462.959 C 220 470.884 224.55 481.867 230.153 487.471 L 252.924 510.241 C 258.527 515.845 263.077 526.829 263.077 534.754 L 263.077 649.625 C 263.078 672.529 269.173 692.394 280.952 710.431 L 400.009 591.373 L 220 429.762 Z " class="icon-mark"/>
|
||||
<path d=" M 667.232 760.147 C 627.163 787.649 570.672 813.211 500 843.472 Q 500 843.472 500 843.472 C 429.328 813.211 372.837 787.649 332.768 760.147 L 454.622 638.293 C 459.461 641.204 464.582 643.644 469.918 645.569 C 479.567 649.058 489.741 650.839 500 650.832 C 510.259 650.839 520.433 649.058 530.082 645.569 C 535.418 643.644 540.539 641.204 545.378 638.293 L 667.232 760.147 Z " class="icon-mark"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
+76
-12
@@ -1,29 +1,71 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
parseDomainBranding,
|
||||
pickRequestHost,
|
||||
type BrandingOverrideKey,
|
||||
} from "@/lib/admin/domain-branding";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type WebAppProtocolHandler = {
|
||||
protocol: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ExtendedManifest = MetadataRoute.Manifest & {
|
||||
protocol_handlers?: WebAppProtocolHandler[];
|
||||
launch_handler?: {
|
||||
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
|
||||
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
|
||||
};
|
||||
};
|
||||
|
||||
// Manifest paths must include the deployment subpath - browsers resolve them
|
||||
// against the document origin, not the manifest's location, and Next.js does
|
||||
// not auto-prefix string literals inside MetadataRoute payloads.
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const host = pickRequestHost(await headers());
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
);
|
||||
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
|
||||
const override = domainOverrides[key];
|
||||
if (typeof override === "string" && override.length > 0) return override as T;
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
branded<string>("appName", "") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||
const shortName = branded<string>("appShortName", "") || appName;
|
||||
const description =
|
||||
process.env.APP_DESCRIPTION ||
|
||||
branded<string>("appDescription", "") ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
|
||||
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
|
||||
|
||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
|
||||
// env var, or per-domain override), serve dynamically resized PNGs via
|
||||
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
|
||||
// sources marked "default" are the built-in placeholder paths and not
|
||||
// real custom icons.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const hasCustomIcon =
|
||||
!!domainOverrides.pwaIconUrl ||
|
||||
!!domainOverrides.faviconUrl ||
|
||||
sources.pwaIconUrl?.source !== "default" ||
|
||||
sources.faviconUrl?.source !== "default";
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
@@ -53,9 +95,31 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
{ src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
// Use admin-uploaded screenshots when configured (per-domain override,
|
||||
// admin/env global; resized on the fly via /api/pwa-screenshot/[variant]);
|
||||
// otherwise fall back to the built-in Bulwark screenshots from public/.
|
||||
screenshots: (() => {
|
||||
const hasMobile =
|
||||
!!domainOverrides.pwaScreenshotMobileUrl ||
|
||||
sources.pwaScreenshotMobileUrl?.source !== "default";
|
||||
const hasDesktop =
|
||||
!!domainOverrides.pwaScreenshotDesktopUrl ||
|
||||
sources.pwaScreenshotDesktopUrl?.source !== "default";
|
||||
return [
|
||||
hasMobile
|
||||
? { src: withBase("/api/pwa-screenshot/mobile"), sizes: "540x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" },
|
||||
hasDesktop
|
||||
? { src: withBase("/api/pwa-screenshot/desktop"), sizes: "1280x720", type: "image/png" }
|
||||
: { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" },
|
||||
];
|
||||
})(),
|
||||
protocol_handlers: [
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
],
|
||||
launch_handler: {
|
||||
client_mode: ["focus-existing", "navigate-new"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,24 +2,56 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
/**
|
||||
* Split a per-account calendar list into "owned" (the user's own) and
|
||||
* "shared" sub-buckets, then group shared by the owning principal so each
|
||||
* delegator gets its own sub-section.
|
||||
*/
|
||||
type AccountCalendarSplit = {
|
||||
owned: Calendar[];
|
||||
sharedGroups: { label: string; calendars: Calendar[] }[];
|
||||
};
|
||||
|
||||
function splitAccountCalendars(list: Calendar[]): AccountCalendarSplit {
|
||||
const owned: Calendar[] = [];
|
||||
const sharedBuckets = new Map<string, { label: string; calendars: Calendar[] }>();
|
||||
for (const cal of list) {
|
||||
if (cal.isShared) {
|
||||
const key = cal.accountId || cal.accountName || cal.id;
|
||||
const bucket = sharedBuckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.calendars.push(cal);
|
||||
} else {
|
||||
sharedBuckets.set(key, { label: cal.accountName || key, calendars: [cal] });
|
||||
}
|
||||
} else {
|
||||
owned.push(cal);
|
||||
}
|
||||
}
|
||||
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
|
||||
}
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
calendars: Calendar[];
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
onShareCalendar?: (calendar: Calendar) => void;
|
||||
onCreateEvent?: (calendar: Calendar) => void;
|
||||
onClearCalendar?: (calendar: Calendar) => void;
|
||||
@@ -28,6 +60,12 @@ interface CalendarSidebarPanelProps {
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
/**
|
||||
* When true, render one collapsible section per connected local account,
|
||||
* mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed
|
||||
* by their `localAccountId` and the active account is shown first.
|
||||
*/
|
||||
multiAccountMode?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarSidebarPanel({
|
||||
@@ -35,6 +73,7 @@ export function CalendarSidebarPanel({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onResetColor,
|
||||
onShareCalendar,
|
||||
onCreateEvent,
|
||||
onClearCalendar,
|
||||
@@ -43,15 +82,22 @@ export function CalendarSidebarPanel({
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
multiAccountMode,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const currentAccountId = client?.getAccountId();
|
||||
const icalSubscriptions = useMemo(
|
||||
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
|
||||
[allSubs, currentAccountId],
|
||||
);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||
@@ -65,6 +111,26 @@ export function CalendarSidebarPanel({
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
|
||||
// Persisted across mounts so toggle state survives tab switches in the
|
||||
// Pro shell (same key family as the mail sidebar's account collapse).
|
||||
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('calendar-sidebar-collapsed-accounts');
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { return new Set(); }
|
||||
});
|
||||
const toggleAccountGroup = (key: string) => {
|
||||
setCollapsedAccountGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
try { localStorage.setItem('calendar-sidebar-collapsed-accounts', JSON.stringify(Array.from(next))); } catch { /* */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const localAccounts = useAccountStore((s) => s.accounts);
|
||||
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
|
||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||
const sharedAccountGroups = useMemo(() => {
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
@@ -79,6 +145,53 @@ export function CalendarSidebarPanel({
|
||||
return Array.from(groups.values());
|
||||
}, [calendars]);
|
||||
|
||||
/**
|
||||
* Pro / multi-account grouping: every calendar bucketed by its owning
|
||||
* local account. Active account comes first, then the rest in their
|
||||
* account-store order. Calendars without a `localAccountId` (e.g. the
|
||||
* birthday calendar) fall into a separate "other" bucket so they still
|
||||
* render.
|
||||
*/
|
||||
const localAccountGroups = useMemo(() => {
|
||||
if (!multiAccountMode) return [];
|
||||
const byAccount = new Map<string, Calendar[]>();
|
||||
for (const cal of calendars) {
|
||||
const key = cal.localAccountId || '__other__';
|
||||
const list = byAccount.get(key) ?? [];
|
||||
list.push(cal);
|
||||
byAccount.set(key, list);
|
||||
}
|
||||
const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = [];
|
||||
// Active account first.
|
||||
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
|
||||
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
|
||||
ordered.push({
|
||||
key: activeLocalAccountId,
|
||||
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
|
||||
split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!),
|
||||
});
|
||||
byAccount.delete(activeLocalAccountId);
|
||||
}
|
||||
// Then the rest in account-store order so the layout matches the mail sidebar.
|
||||
for (const acct of localAccounts) {
|
||||
if (!byAccount.has(acct.id)) continue;
|
||||
ordered.push({
|
||||
key: acct.id,
|
||||
label: acct.label || acct.email || acct.username,
|
||||
split: splitAccountCalendars(byAccount.get(acct.id)!),
|
||||
});
|
||||
byAccount.delete(acct.id);
|
||||
}
|
||||
// Any leftover buckets (deleted accounts, untagged calendars).
|
||||
for (const [key, list] of byAccount.entries()) {
|
||||
const fallbackLabel = key === '__other__'
|
||||
? t('my_calendars')
|
||||
: list[0]?.accountName || key;
|
||||
ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) });
|
||||
}
|
||||
return ordered;
|
||||
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
|
||||
|
||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
};
|
||||
@@ -194,9 +307,11 @@ export function CalendarSidebarPanel({
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canChangeColor = !!onColorChange;
|
||||
const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)];
|
||||
const canResetColor = !!onResetColor && hasColorOverride;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||
const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
@@ -226,6 +341,13 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{canResetColor && (
|
||||
<ContextMenuItem
|
||||
icon={Shuffle}
|
||||
label={tMgmt('random_color')}
|
||||
onClick={() => { closeContextMenu(); onResetColor!(cal); }}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
@@ -263,37 +385,110 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
|
||||
{sharedAccountGroups.map((group) => (
|
||||
<div key={group.accountName} className="mt-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
<>
|
||||
{localAccountGroups.map((group, idx) => {
|
||||
const expanded = !collapsedAccountGroups.has(group.key);
|
||||
const isActive = group.key === activeLocalAccountId;
|
||||
const { owned, sharedGroups } = group.split;
|
||||
return (
|
||||
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
|
||||
<button
|
||||
onClick={() => toggleAccountGroup(group.key)}
|
||||
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground/90 truncate">
|
||||
{group.label}
|
||||
</span>
|
||||
{isActive && onCreateCalendar && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCreateCalendar();
|
||||
}
|
||||
}}
|
||||
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 pl-3">
|
||||
{owned.length > 0 && (
|
||||
<div>
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{owned.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharedGroups.map((sg) => (
|
||||
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{sg.label}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{sg.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
|
||||
{sharedAccountGroups.map((group) => (
|
||||
<div key={group.accountName} className="mt-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderCalendarMenu()}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
|
||||
selectedCalendarIds?: string[];
|
||||
onToggleVisibility?: (id: string) => void;
|
||||
enableCalendarTasks?: boolean;
|
||||
/** Show a burger button at the start that opens the (overlay) sidebar. */
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
@@ -45,6 +47,7 @@ export function CalendarToolbar({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
enableCalendarTasks,
|
||||
onMenuClick,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
@@ -115,11 +118,32 @@ export function CalendarToolbar({
|
||||
|
||||
return (
|
||||
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
|
||||
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
|
||||
{onMenuClick && !isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuClick}
|
||||
className="h-8 w-8 -ml-1 mr-1"
|
||||
aria-label={t("nav_open_menu")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* ── MOBILE TOOLBAR ── */}
|
||||
{isMobile && (
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
{/* Row 1: Back / Date nav / Today */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||
aria-label={t("nav_open_menu")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onNavigateBack && (
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user