Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+158
@@ -1,5 +1,163 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 1.6.5 (2026-05-13)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+12
-12
@@ -101,24 +101,24 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
|||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
1. **Never hardcode user-facing text** — always use translations:
|
1. **Never hardcode user-facing text** - always use translations:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const t = useTranslations("namespace");
|
const t = useTranslations("namespace");
|
||||||
return <div>{t("key")}</div>;
|
return <div>{t("key")}</div>;
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **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.
|
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**:
|
3. **Namespace organization**:
|
||||||
- `login.*` — login page
|
- `login.*` - login page
|
||||||
- `sidebar.*` — sidebar navigation
|
- `sidebar.*` - sidebar navigation
|
||||||
- `email_list.*` — email list
|
- `email_list.*` - email list
|
||||||
- `email_viewer.*` — email viewer
|
- `email_viewer.*` - email viewer
|
||||||
- `email_composer.*` — composer
|
- `email_composer.*` - composer
|
||||||
- `settings.*` — settings page
|
- `settings.*` - settings page
|
||||||
- `notifications.*` — toasts and alerts
|
- `notifications.*` - toasts and alerts
|
||||||
- `common.*` — shared strings
|
- `common.*` - shared strings
|
||||||
|
|
||||||
4. **Locale-aware navigation**:
|
4. **Locale-aware navigation**:
|
||||||
|
|
||||||
@@ -200,9 +200,9 @@ webmail/
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **Never commit secrets** — API keys, passwords, tokens, `.env*` files
|
- **Never commit secrets** - API keys, passwords, tokens, `.env*` files
|
||||||
- **Sanitize user input** and email content
|
- **Sanitize user input** and email content
|
||||||
- **Block external content** by default — privacy is the point
|
- **Block external content** by default - privacy is the point
|
||||||
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
|
- **Report vulnerabilities privately** to bulwark@rbm.systems, not via public issues
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
17 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||||
|
|
||||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
[](https://grafana.external.bulwarkmail.org/)
|
[](https://grafana.external.bulwarkmail.org/)
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -78,7 +78,69 @@ function OAuthCallbackInner() {
|
|||||||
setError("token_exchange_failed");
|
setError("token_exchange_failed");
|
||||||
});
|
});
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
// Server-side SSO flow - state was stored in encrypted httpOnly cookie.
|
||||||
|
// Branch on mobile handoff first: the login page left a marker in
|
||||||
|
// sessionStorage if it kicked this OAuth dance off for the mobile app.
|
||||||
|
let mobileRedirectUri: string | null = null;
|
||||||
|
let mobileState: string | null = null;
|
||||||
|
try {
|
||||||
|
mobileRedirectUri = sessionStorage.getItem("mobile_redirect_uri");
|
||||||
|
mobileState = sessionStorage.getItem("mobile_state");
|
||||||
|
} catch { /* sessionStorage may be unavailable */ }
|
||||||
|
|
||||||
|
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
|
||||||
|
// Drive /api/auth/sso/complete directly so we can read the tokens
|
||||||
|
// out of the response - loginWithServerSso would consume them and
|
||||||
|
// wire up the webmail auth store, which isn't useful here. The
|
||||||
|
// server's mobile-flow branch (keyed on the pending cookie) skips
|
||||||
|
// the refresh-token cookie write for the same reason.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/sso/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const serverUrl = data.server_url as string | undefined;
|
||||||
|
const accessToken = data.access_token as string | undefined;
|
||||||
|
const tokenEndpoint = data.token_endpoint as string | undefined;
|
||||||
|
const clientId = data.client_id as string | undefined;
|
||||||
|
if (!serverUrl || !accessToken || !tokenEndpoint || !clientId) {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = new URLSearchParams({
|
||||||
|
flow: "oauth",
|
||||||
|
server_url: serverUrl,
|
||||||
|
access_token: accessToken,
|
||||||
|
token_endpoint: tokenEndpoint,
|
||||||
|
client_id: clientId,
|
||||||
|
state: mobileState ?? "",
|
||||||
|
});
|
||||||
|
if (typeof data.refresh_token === "string") {
|
||||||
|
fragment.set("refresh_token", data.refresh_token);
|
||||||
|
}
|
||||||
|
if (typeof data.expires_in === "number") {
|
||||||
|
fragment.set("expires_in", String(data.expires_in));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem("mobile_redirect_uri");
|
||||||
|
sessionStorage.removeItem("mobile_state");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||||
|
} catch {
|
||||||
|
setError("token_exchange_failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||||
loginWithServerSso(code, state)
|
loginWithServerSso(code, state)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
@@ -17,7 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||||
@@ -32,6 +32,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan
|
|||||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||||
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
@@ -44,6 +45,8 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
|||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
@@ -74,6 +77,13 @@ export default function CalendarPage() {
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
|
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
|
||||||
|
// collapses into a burger-toggled overlay instead of taking inline space.
|
||||||
|
const isNarrow = !isDesktop;
|
||||||
|
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -254,12 +264,17 @@ export default function CalendarPage() {
|
|||||||
return subscribeToPendingWebcal(openPendingWebcal);
|
return subscribeToPendingWebcal(openPendingWebcal);
|
||||||
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && !hasFetched.current) {
|
if (client && !hasFetched.current) {
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
fetchCalendars(client);
|
fetchCalendars(client);
|
||||||
}
|
}
|
||||||
}, [client, fetchCalendars]);
|
}, [client, fetchCalendars, isEmbedded]);
|
||||||
|
|
||||||
// Auto-refresh iCal subscriptions
|
// Auto-refresh iCal subscriptions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -328,10 +343,21 @@ export default function CalendarPage() {
|
|||||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && calendars.length > 0 && dateRange) {
|
if (client && calendars.length > 0 && dateRange) {
|
||||||
fetchEvents(client, dateRange.start, dateRange.end);
|
fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
}
|
}
|
||||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
|
||||||
|
|
||||||
|
// Pro shell only: aggregate calendars and events from every connected
|
||||||
|
// account so the sidebar lists them all (and the views render their
|
||||||
|
// events together). The hook is a no-op outside the embedded shell.
|
||||||
|
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
|
||||||
|
isEmbedded ? dateRange?.start ?? null : null,
|
||||||
|
isEmbedded ? dateRange?.end ?? null : null,
|
||||||
|
);
|
||||||
|
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||||
|
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||||
|
|
||||||
const navigatePrev = useCallback(() => {
|
const navigatePrev = useCallback(() => {
|
||||||
let next: Date;
|
let next: Date;
|
||||||
@@ -403,6 +429,8 @@ export default function CalendarPage() {
|
|||||||
setMobileReturnToMonth(true);
|
setMobileReturnToMonth(true);
|
||||||
setViewMode("day");
|
setViewMode("day");
|
||||||
}
|
}
|
||||||
|
// Close the narrow-pane sidebar overlay after the user picks a date.
|
||||||
|
setNarrowSidebarOpen(false);
|
||||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||||
|
|
||||||
const navigateBackToMonth = useCallback(() => {
|
const navigateBackToMonth = useCallback(() => {
|
||||||
@@ -553,12 +581,15 @@ export default function CalendarPage() {
|
|||||||
}, [events, client]);
|
}, [events, client]);
|
||||||
|
|
||||||
const refetchCurrentRange = useCallback(async () => {
|
const refetchCurrentRange = useCallback(async () => {
|
||||||
if (!client) return;
|
if (!client || !activeAccountId) return;
|
||||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||||
if (currentRange) {
|
if (!currentRange) return;
|
||||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||||
|
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh calendar data via JMAP instead of reloading the page.
|
// and refresh calendar data via JMAP instead of reloading the page.
|
||||||
@@ -566,8 +597,11 @@ export default function CalendarPage() {
|
|||||||
enabled: isAuthenticated && !!client,
|
enabled: isAuthenticated && !!client,
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||||
|
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||||
|
: fetchCalendars(client);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchCalendars(client),
|
calendarRefresh,
|
||||||
refetchCurrentRange(),
|
refetchCurrentRange(),
|
||||||
refreshAllSubscriptions(client),
|
refreshAllSubscriptions(client),
|
||||||
]);
|
]);
|
||||||
@@ -1216,9 +1250,11 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
{/* Left Navigation Rail */}
|
<AppTopBannerSlot />
|
||||||
{!isMobile && (
|
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||||
|
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
|
||||||
|
{!isMobile && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -1237,15 +1273,31 @@ export default function CalendarPage() {
|
|||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar - full height */}
|
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
|
||||||
{!isMobile && !inlineApp && (
|
{isNarrow && narrowSidebarOpen && !inlineApp && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inset-0 bg-black/50 z-40",
|
||||||
|
isEmbedded ? "absolute" : "fixed"
|
||||||
|
)}
|
||||||
|
onClick={() => setNarrowSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
|
||||||
|
{!inlineApp && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||||
!isResizing && "transition-[width] duration-300"
|
!isResizing && "transition-[width] duration-300",
|
||||||
|
isNarrow && cn(
|
||||||
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
|
!narrowSidebarOpen && "-translate-x-full"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
style={{ width: `${calSidebarWidth}px` }}
|
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
<MiniCalendar
|
<MiniCalendar
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
@@ -1306,17 +1358,20 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
|
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
{!isNarrow && (
|
||||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
<ResizeHandle
|
||||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||||
onResizeEnd={() => {
|
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||||
setIsResizing(false);
|
onResizeEnd={() => {
|
||||||
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
setIsResizing(false);
|
||||||
}}
|
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
||||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
}}
|
||||||
/>
|
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1338,6 +1393,7 @@ export default function CalendarPage() {
|
|||||||
selectedCalendarIds={selectedCalendarIds}
|
selectedCalendarIds={selectedCalendarIds}
|
||||||
onToggleVisibility={toggleCalendarVisibility}
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
enableCalendarTasks={enableCalendarTasks}
|
enableCalendarTasks={enableCalendarTasks}
|
||||||
|
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -1410,7 +1466,7 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation */}
|
{/* Mobile Bottom Navigation */}
|
||||||
{isMobile && (
|
{isMobile && !isEmbedded && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
@@ -1580,6 +1636,7 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowLeft, Users } from "lucide-react";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
@@ -15,17 +17,21 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
|
|||||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||||
import { exportContacts } from "@/components/contacts/contact-export";
|
import { exportContacts } from "@/components/contacts/contact-export";
|
||||||
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { cn, generateUUID } from "@/lib/utils";
|
import { cn, generateUUID } from "@/lib/utils";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||||
@@ -42,6 +48,7 @@ type View =
|
|||||||
|
|
||||||
export default function ContactsPage() {
|
export default function ContactsPage() {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
@@ -88,11 +95,26 @@ export default function ContactsPage() {
|
|||||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||||
|
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||||
|
const [returnToEmail, setReturnToEmail] = useState(false);
|
||||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
// One-shot intent flag: only consume the URL params on the first render that
|
||||||
|
// has them. After applying, we strip the query so a later refresh or
|
||||||
|
// re-mount doesn't re-trigger the navigation.
|
||||||
|
const intentAppliedRef = useRef(false);
|
||||||
|
// Narrow pane (Pro split or small window): the categories sidebar collapses
|
||||||
|
// into a burger-toggled overlay.
|
||||||
|
const isNarrow = !isDesktop;
|
||||||
|
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||||
|
|
||||||
// Panel resize state - sidebar (categories)
|
// Panel resize state - sidebar (categories)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
@@ -129,12 +151,41 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
// Pro shell only: aggregate contacts and address books from every
|
||||||
|
// connected account so the sidebar lists them all. The hook is a no-op
|
||||||
|
// outside the embedded shell.
|
||||||
|
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && supportsSync && !hasFetched.current) {
|
if (client && supportsSync && !hasFetched.current) {
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
fetchContacts(client);
|
fetchContacts(client);
|
||||||
}
|
}
|
||||||
}, [client, supportsSync, fetchContacts]);
|
}, [client, supportsSync, fetchContacts, isEmbedded]);
|
||||||
|
|
||||||
|
// Consume one-shot URL params (set by the mobile recipient popover when no
|
||||||
|
// sidebar is available) and strip them so a refresh doesn't replay the
|
||||||
|
// intent. `from=email` flips the mobile back button to `router.back()`.
|
||||||
|
useEffect(() => {
|
||||||
|
if (intentAppliedRef.current) return;
|
||||||
|
const contactId = searchParams.get('contactId');
|
||||||
|
const addEmail = searchParams.get('addEmail');
|
||||||
|
const addName = searchParams.get('addName');
|
||||||
|
const from = searchParams.get('from');
|
||||||
|
if (!contactId && !addEmail && !from) return;
|
||||||
|
intentAppliedRef.current = true;
|
||||||
|
if (from === 'email') setReturnToEmail(true);
|
||||||
|
if (contactId) {
|
||||||
|
setSelectedContact(contactId);
|
||||||
|
setView('detail');
|
||||||
|
} else if (addEmail) {
|
||||||
|
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||||
|
setSelectedContact(null);
|
||||||
|
setView('create');
|
||||||
|
}
|
||||||
|
router.replace('/contacts');
|
||||||
|
}, [searchParams, router, setSelectedContact]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh contacts via JMAP instead of reloading the page.
|
// and refresh contacts via JMAP instead of reloading the page.
|
||||||
@@ -142,6 +193,17 @@ export default function ContactsPage() {
|
|||||||
enabled: isAuthenticated && !!client && supportsSync,
|
enabled: isAuthenticated && !!client && supportsSync,
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
const activeId = useAuthStore.getState().activeAccountId;
|
||||||
|
if (activeId) {
|
||||||
|
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||||
|
await Promise.all([
|
||||||
|
fetchAllAccountsAddressBooks(accountClients, activeId),
|
||||||
|
fetchAllAccountsContacts(accountClients, activeId),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await fetchContacts(client);
|
await fetchContacts(client);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -193,6 +255,7 @@ export default function ContactsPage() {
|
|||||||
} else {
|
} else {
|
||||||
setSelectedGroupId(null);
|
setSelectedGroupId(null);
|
||||||
}
|
}
|
||||||
|
setNarrowSidebarOpen(false);
|
||||||
}, [clearSelection]);
|
}, [clearSelection]);
|
||||||
|
|
||||||
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||||
@@ -335,8 +398,14 @@ export default function ContactsPage() {
|
|||||||
toast.success(t("toast.created"));
|
toast.success(t("toast.created"));
|
||||||
}
|
}
|
||||||
setDefaultBookIdForCreate(undefined);
|
setDefaultBookIdForCreate(undefined);
|
||||||
|
setCreatePrefill(undefined);
|
||||||
|
if (returnToEmail) {
|
||||||
|
setReturnToEmail(false);
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setView("list");
|
setView("list");
|
||||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||||
if (!selectedContact) return;
|
if (!selectedContact) return;
|
||||||
@@ -353,6 +422,14 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setDefaultBookIdForCreate(undefined);
|
setDefaultBookIdForCreate(undefined);
|
||||||
|
// Came from email → cancel returns to the email instead of the contact list.
|
||||||
|
if (returnToEmail && view === "create") {
|
||||||
|
setCreatePrefill(undefined);
|
||||||
|
setReturnToEmail(false);
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (view === "create") setCreatePrefill(undefined);
|
||||||
if (view === "group-create" || view === "group-edit") {
|
if (view === "group-create" || view === "group-edit") {
|
||||||
setView(selectedGroup ? "group-detail" : "list");
|
setView(selectedGroup ? "group-detail" : "list");
|
||||||
} else if (view === "bulk-add-to-group") {
|
} else if (view === "bulk-add-to-group") {
|
||||||
@@ -524,7 +601,7 @@ export default function ContactsPage() {
|
|||||||
const renderRightPanel = () => {
|
const renderRightPanel = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "create":
|
case "create":
|
||||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
if (!selectedContact) return null;
|
if (!selectedContact) return null;
|
||||||
@@ -640,18 +717,38 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!contactsEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh items-center justify-center bg-background p-6">
|
||||||
|
<div className="max-w-lg text-center space-y-3">
|
||||||
|
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||||
|
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const showListPanel = !isMobile || view === "list";
|
const showListPanel = !isMobile || view === "list";
|
||||||
const showRightPanel = !isMobile || view !== "list";
|
const showRightPanel = !isMobile || view !== "list";
|
||||||
|
|
||||||
const mobileBackToList = () => {
|
const mobileBackToList = () => {
|
||||||
|
if (returnToEmail) {
|
||||||
|
setReturnToEmail(false);
|
||||||
|
setCreatePrefill(undefined);
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setView("list");
|
setView("list");
|
||||||
clearSelection();
|
clearSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-dvh bg-background overflow-hidden", isMobile && "flex-col")}>
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
{/* Navigation Rail - desktop only */}
|
<AppTopBannerSlot />
|
||||||
{!isMobile && (
|
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||||
|
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||||
|
{!isMobile && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -670,18 +767,33 @@ export default function ContactsPage() {
|
|||||||
{inlineApp && (
|
{inlineApp && (
|
||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||||
)}
|
)}
|
||||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||||
|
{/* Narrow-pane backdrop for the overlay categories sidebar */}
|
||||||
|
{isNarrow && narrowSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inset-0 bg-black/50 z-40",
|
||||||
|
isEmbedded ? "absolute" : "fixed"
|
||||||
|
)}
|
||||||
|
onClick={() => setNarrowSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showListPanel && (
|
{showListPanel && (
|
||||||
<>
|
<>
|
||||||
{/* Panel 1: Categories sidebar */}
|
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
|
||||||
{!isMobile && (
|
{(!isMobile || isNarrow) && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border flex flex-col flex-shrink-0",
|
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
||||||
!isSidebarResizing && "transition-[width] duration-300"
|
!isSidebarResizing && "transition-[width] duration-300",
|
||||||
|
isNarrow && cn(
|
||||||
|
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
|
!narrowSidebarOpen && "-translate-x-full"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
style={{ width: `${sidebarWidth}px` }}
|
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
<ContactsSidebar
|
<ContactsSidebar
|
||||||
groups={groups}
|
groups={groups}
|
||||||
@@ -718,17 +830,20 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||||
|
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
{!isNarrow && (
|
||||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
<ResizeHandle
|
||||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||||
onResizeEnd={() => {
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||||
setIsSidebarResizing(false);
|
onResizeEnd={() => {
|
||||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
setIsSidebarResizing(false);
|
||||||
}}
|
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
}}
|
||||||
/>
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -761,6 +876,7 @@ export default function ContactsPage() {
|
|||||||
onEditContact={handleEditContact}
|
onEditContact={handleEditContact}
|
||||||
onDeleteContact={handleDeleteContact}
|
onDeleteContact={handleDeleteContact}
|
||||||
onAddContactToGroup={handleAddContactToGroup}
|
onAddContactToGroup={handleAddContactToGroup}
|
||||||
|
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -790,7 +906,7 @@ export default function ContactsPage() {
|
|||||||
className="touch-manipulation"
|
className="touch-manipulation"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
{t("back_to_contacts")}
|
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -801,7 +917,7 @@ export default function ContactsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && !isEmbedded && (
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -882,6 +998,7 @@ export default function ContactsPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -16,6 +17,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
|||||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
@@ -24,6 +26,7 @@ import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
|||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
@@ -31,6 +34,9 @@ export default function FilesPage() {
|
|||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
@@ -83,6 +89,7 @@ export default function FilesPage() {
|
|||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
@@ -127,13 +134,18 @@ export default function FilesPage() {
|
|||||||
}
|
}
|
||||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
// Initialize JMAP files client
|
// Initialize JMAP files client. In the Pro shell, all connected accounts
|
||||||
|
// are surfaced as top-level folders at the root, so we *don't* auto-attach
|
||||||
|
// to the active account - the user picks one explicitly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && client && !hasFetched.current) {
|
if (!isAuthenticated || !client || hasFetched.current) return;
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
initClient(client);
|
if (isEmbedded) {
|
||||||
|
useFileStore.getState().clearClient();
|
||||||
|
} else {
|
||||||
|
initClient(client, activeAccountId);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, client, initClient]);
|
}, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh files via JMAP instead of reloading the page.
|
// and refresh files via JMAP instead of reloading the page.
|
||||||
@@ -157,6 +169,17 @@ export default function FilesPage() {
|
|||||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||||
|
|
||||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||||
|
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||||
|
// account's filesystem root" via a sentinel, distinguishing it from a
|
||||||
|
// Home click (which detaches the account and returns to the picker).
|
||||||
|
if (resourceId === '__account_root__') {
|
||||||
|
void navigate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEmbedded && path === '/' && resourceId === undefined) {
|
||||||
|
useFileStore.getState().clearClient();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resourceId !== undefined) {
|
if (resourceId !== undefined) {
|
||||||
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
||||||
navigate(resourceId, path.split('/').pop() || '');
|
navigate(resourceId, path.split('/').pop() || '');
|
||||||
@@ -164,7 +187,7 @@ export default function FilesPage() {
|
|||||||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||||||
navigateByPath(path);
|
navigateByPath(path);
|
||||||
}
|
}
|
||||||
}, [navigate, navigateByPath]);
|
}, [navigate, navigateByPath, isEmbedded]);
|
||||||
|
|
||||||
const handleCreateFolder = useCallback(async (name: string) => {
|
const handleCreateFolder = useCallback(async (name: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -371,11 +394,45 @@ export default function FilesPage() {
|
|||||||
setShowDetails(v => !v);
|
setShowDetails(v => !v);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||||
|
|
||||||
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
|
// account's files are shown until the user enters one.
|
||||||
|
const accountFolders = isEmbedded
|
||||||
|
? accounts
|
||||||
|
.filter((a) => a.isConnected)
|
||||||
|
.map((a) => ({
|
||||||
|
accountId: a.id,
|
||||||
|
label: a.label || a.email,
|
||||||
|
email: a.email,
|
||||||
|
avatarColor: a.avatarColor,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
|
||||||
|
const currentAccountLabel = isEmbedded && currentFilesAccountId
|
||||||
|
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|
||||||
|
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|
||||||
|
|| null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelectAccount = useCallback((accountId: string) => {
|
||||||
|
const nextClient = getClientForAccount(accountId);
|
||||||
|
if (!nextClient) return;
|
||||||
|
const store = useFileStore.getState();
|
||||||
|
store.initClient(nextClient, accountId);
|
||||||
|
// Reset supportsFiles so the existing checkSupport effect re-runs for
|
||||||
|
// the freshly-attached client and triggers the initial navigate(null).
|
||||||
|
useFileStore.setState({ supportsFiles: null });
|
||||||
|
}, [getClientForAccount]);
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
{!isMobile && (
|
<AppTopBannerSlot />
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
{!isMobile && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -396,7 +453,7 @@ export default function FilesPage() {
|
|||||||
)}
|
)}
|
||||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{folderLayout !== "sidebar" && (
|
{folderLayout !== "sidebar" && !isEmbedded && (
|
||||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
@@ -474,6 +531,10 @@ export default function FilesPage() {
|
|||||||
showDetails={showDetails}
|
showDetails={showDetails}
|
||||||
onToggleDetails={handleToggleDetails}
|
onToggleDetails={handleToggleDetails}
|
||||||
detailResource={detailResource}
|
detailResource={detailResource}
|
||||||
|
accountFolders={accountFolders}
|
||||||
|
onSelectAccount={handleSelectAccount}
|
||||||
|
accountPickerMode={isAccountPicker}
|
||||||
|
accountLabel={currentAccountLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -481,7 +542,7 @@ export default function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && !isEmbedded && (
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -514,6 +575,7 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,9 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
|
|||||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||||
|
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||||
|
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||||
|
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -34,7 +37,10 @@ export default async function LocaleLayout({
|
|||||||
<EmbeddedBridgeProvider>
|
<EmbeddedBridgeProvider>
|
||||||
<TourProvider>
|
<TourProvider>
|
||||||
<ProtocolLaunchHandlerProvider>
|
<ProtocolLaunchHandlerProvider>
|
||||||
|
<ProInterfaceRedirect />
|
||||||
{children}
|
{children}
|
||||||
|
<PluginDialogHost />
|
||||||
|
<PluginConsentDialog />
|
||||||
</ProtocolLaunchHandlerProvider>
|
</ProtocolLaunchHandlerProvider>
|
||||||
</TourProvider>
|
</TourProvider>
|
||||||
</EmbeddedBridgeProvider>
|
</EmbeddedBridgeProvider>
|
||||||
@@ -16,7 +16,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
|
||||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
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() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
|
|
||||||
|
// When the mobile app launches the webmail in a browser tab it tacks on
|
||||||
|
// these params. We grab them once at mount and stash them in a ref so any
|
||||||
|
// login path that completes (password or OAuth) can hand control back to
|
||||||
|
// the app instead of routing into /mail.
|
||||||
|
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
|
||||||
|
const rawMobileState = searchParams.get("mobile_state") ?? "";
|
||||||
|
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
|
||||||
|
? rawMobileRedirectUri
|
||||||
|
: "";
|
||||||
|
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||||
|
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||||
const { appName, jmapServerUrl: 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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -160,6 +176,9 @@ export default function LoginPage() {
|
|||||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||||
const prevError = useRef<string | null>(null);
|
const prevError = useRef<string | null>(null);
|
||||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Captured by handleSubmit when in mobile handoff mode; consumed by the
|
||||||
|
// isAuthenticated effect to build the deep-link fragment.
|
||||||
|
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
initializeTheme();
|
||||||
@@ -239,6 +258,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !isAddAccountMode) {
|
if (isAuthenticated && !isAddAccountMode) {
|
||||||
|
// Mobile handoff: the password path completes here once the auth store
|
||||||
|
// flips isAuthenticated. Hand the verified credentials back to the
|
||||||
|
// mobile app instead of pushing to /mail. handleSubmit captured the
|
||||||
|
// values needed for the fragment.
|
||||||
|
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
|
||||||
|
const fragment = new URLSearchParams({
|
||||||
|
flow: "password",
|
||||||
|
...mobileHandoffPayloadRef.current,
|
||||||
|
state: mobileState,
|
||||||
|
});
|
||||||
|
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let redirectTo = '/';
|
let redirectTo = '/';
|
||||||
try {
|
try {
|
||||||
const saved = sessionStorage.getItem('redirect_after_login');
|
const saved = sessionStorage.getItem('redirect_after_login');
|
||||||
@@ -249,7 +281,7 @@ export default function LoginPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router, isAddAccountMode]);
|
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -317,6 +349,16 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
// In mobile-handoff mode the callback page needs to know it should
|
||||||
|
// redirect into the app rather than into /mail. Stash the params in
|
||||||
|
// sessionStorage so the same-tab callback can read them - the SSO
|
||||||
|
// pending cookie carries the authoritative copy server-side too.
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
|
||||||
|
sessionStorage.setItem("mobile_state", mobileState);
|
||||||
|
} catch { /* sessionStorage unavailable */ }
|
||||||
|
}
|
||||||
const res = await apiFetch('/api/auth/sso/start', {
|
const res = await apiFetch('/api/auth/sso/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -325,6 +367,9 @@ export default function LoginPage() {
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
locale: params.locale,
|
locale: params.locale,
|
||||||
server_id: selectedServer?.id,
|
server_id: selectedServer?.id,
|
||||||
|
...(isMobileHandoff
|
||||||
|
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,7 +396,7 @@ export default function LoginPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setOauthLoading(false);
|
setOauthLoading(false);
|
||||||
}
|
}
|
||||||
}, [params.locale, selectedServer?.id]);
|
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
@@ -493,6 +538,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
if (!oauthMetadata || !effectiveOauthClientId) return;
|
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||||
|
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
|
||||||
|
// tokens would land in sessionStorage on the webmail origin and the
|
||||||
|
// mobile app couldn't read them. Route through the server-side SSO
|
||||||
|
// path instead, which has the mobile-aware /api/auth/sso/complete
|
||||||
|
// branch.
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
await startServerSideSso();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
|
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
@@ -532,7 +586,7 @@ export default function LoginPage() {
|
|||||||
authUrl.searchParams.set("response_type", "code");
|
authUrl.searchParams.set("response_type", "code");
|
||||||
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||||
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
authUrl.searchParams.set("scope", oauthScopes || "openid email profile");
|
||||||
authUrl.searchParams.set("state", state);
|
authUrl.searchParams.set("state", state);
|
||||||
authUrl.searchParams.set("code_challenge", challenge);
|
authUrl.searchParams.set("code_challenge", challenge);
|
||||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||||
@@ -547,6 +601,16 @@ export default function LoginPage() {
|
|||||||
// when the admin hasn't configured a server list.
|
// when the admin hasn't configured a server list.
|
||||||
const effectiveServerUrl = selectedServer?.url
|
const effectiveServerUrl = selectedServer?.url
|
||||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||||
|
// Capture before login() so the isAuthenticated effect can build the
|
||||||
|
// deep-link fragment with values the user actually typed (formData may
|
||||||
|
// be cleared by the auth store on success).
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
mobileHandoffPayloadRef.current = {
|
||||||
|
server_url: effectiveServerUrl,
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
const success = await login(
|
const success = await login(
|
||||||
effectiveServerUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
@@ -557,7 +621,15 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
|
if (isMobileHandoff) {
|
||||||
|
// The isAuthenticated effect handles the redirect; nothing else to
|
||||||
|
// do here. Don't push to / - that would race the deep link.
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push('/');
|
router.push('/');
|
||||||
|
} else if (isMobileHandoff) {
|
||||||
|
// Stale payload should never feed into a later retry's redirect.
|
||||||
|
mobileHandoffPayloadRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +49,9 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||||
|
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
@@ -60,12 +63,16 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
|
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
|
import { useLocaleStore } from "@/stores/locale-store";
|
||||||
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -78,6 +85,9 @@ export default function Home() {
|
|||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
|
// Plugin-resolved quote header for the next reply/forward composer open.
|
||||||
|
// Cleared on close so a subsequent "compose new" doesn't reuse stale state.
|
||||||
|
const [composerQuoteHeader, setComposerQuoteHeader] = useState<QuoteHeader | null>(null);
|
||||||
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||||
@@ -210,6 +220,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
|
||||||
const {
|
const {
|
||||||
emails,
|
emails,
|
||||||
@@ -271,8 +282,16 @@ export default function Home() {
|
|||||||
batchMarkAsRead,
|
batchMarkAsRead,
|
||||||
batchMarkAsSpam,
|
batchMarkAsSpam,
|
||||||
batchUndoSpam,
|
batchUndoSpam,
|
||||||
|
accountMailboxes,
|
||||||
|
viewingAccountId,
|
||||||
|
selectAccountMailbox,
|
||||||
|
setViewingAccount,
|
||||||
} = useEmailStore();
|
} = useEmailStore();
|
||||||
|
|
||||||
|
// Pro shell: populate per-account mailbox cache so the sidebar can render
|
||||||
|
// every connected account Thunderbird-style.
|
||||||
|
useProMultiAccountMailboxes();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
const accounts = useAccountStore((s) => s.accounts);
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
const connectedAccountsSignature = useMemo(
|
const connectedAccountsSignature = useMemo(
|
||||||
@@ -622,6 +641,65 @@ export default function Home() {
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
|
||||||
|
|
||||||
|
// When this page is rendered inside the Pro shell as the Mail tab body,
|
||||||
|
// we hoist every "show composer" intent into its own Pro tab and reset
|
||||||
|
// the in-page state so the inline composer never appears in the Mail tab.
|
||||||
|
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEmbedded || !showComposer) return;
|
||||||
|
const replyTo = selectedEmail ? {
|
||||||
|
from: selectedEmail.from,
|
||||||
|
replyToAddresses: selectedEmail.replyTo,
|
||||||
|
to: selectedEmail.to,
|
||||||
|
cc: selectedEmail.cc,
|
||||||
|
bcc: selectedEmail.bcc,
|
||||||
|
subject: selectedEmail.subject,
|
||||||
|
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||||
|
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||||
|
receivedAt: selectedEmail.receivedAt,
|
||||||
|
attachments: selectedEmail.attachments,
|
||||||
|
messageId: selectedEmail.messageId,
|
||||||
|
inReplyTo: selectedEmail.inReplyTo,
|
||||||
|
references: selectedEmail.references,
|
||||||
|
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||||
|
quoteHeaderText: composerQuoteHeader?.text,
|
||||||
|
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||||
|
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||||
|
let title = t('email_composer.new_message');
|
||||||
|
if (baseSubject) {
|
||||||
|
if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
|
||||||
|
title = baseSubject.startsWith('Re:') ? baseSubject : `Re: ${baseSubject}`;
|
||||||
|
} else if (effectiveMode === 'forward') {
|
||||||
|
title = baseSubject.startsWith('Fwd:') ? baseSubject : `Fwd: ${baseSubject}`;
|
||||||
|
} else {
|
||||||
|
title = baseSubject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useProTabStore.getState().openComposeTab({
|
||||||
|
sessionId: composerSessionId + 1,
|
||||||
|
mode: effectiveMode,
|
||||||
|
replyTo,
|
||||||
|
initialDraftText: composerDraftText,
|
||||||
|
initialData: pendingDraft,
|
||||||
|
sourceEmailId: selectedEmail?.id ?? null,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
|
||||||
|
setComposerSessionId((s) => s + 1);
|
||||||
|
setShowComposer(false);
|
||||||
|
setComposerDraftText("");
|
||||||
|
setPendingDraft(null);
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
|
// We only react to the rising edge of `showComposer` here; the other
|
||||||
|
// variables read above are captured-but-stale-safe because the next
|
||||||
|
// open will fire a fresh effect with new values.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isEmbedded, showComposer]);
|
||||||
|
|
||||||
// Check auth on mount – skip when already authenticated so that navigating
|
// Check auth on mount – skip when already authenticated so that navigating
|
||||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||||
@@ -806,15 +884,17 @@ export default function Home() {
|
|||||||
// Keep unified mailbox counts in sync when the feature is enabled and more
|
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||||
// than one account is connected. Runs whenever the set of connected accounts
|
// than one account is connected. Runs whenever the set of connected accounts
|
||||||
// or the primary account's mailboxes change (a proxy for "something worth
|
// or the primary account's mailboxes change (a proxy for "something worth
|
||||||
// recounting happened").
|
// recounting happened"). The Pro shell always renders the unified mailbox
|
||||||
|
// regardless of the user setting, so refresh when embedded too.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
if (!enableUnifiedMailbox && !isEmbedded) return;
|
||||||
|
if (!isAuthenticated || !client) return;
|
||||||
const built = buildUnifiedAccounts();
|
const built = buildUnifiedAccounts();
|
||||||
if (built.length < 2) return;
|
if (built.length < 2) return;
|
||||||
populateUnifiedAccountMailboxes(built).then((populated) => {
|
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||||
refreshUnifiedCounts(populated);
|
refreshUnifiedCounts(populated);
|
||||||
});
|
});
|
||||||
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
}, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||||
|
|
||||||
// System-notification click handler. The push SW navigates the user back
|
// System-notification click handler. The push SW navigates the user back
|
||||||
// here with `?email=<id>` (specific email it built the toast from) or
|
// here with `?email=<id>` (specific email it built the toast from) or
|
||||||
@@ -999,6 +1079,50 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build the quote header for a reply/forward open, running it through the
|
||||||
|
// emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores
|
||||||
|
// the result in composerQuoteHeader; the render site spreads it into
|
||||||
|
// EmailComposer.replyTo. Errors fall back to the composer's built-in
|
||||||
|
// header (state set to null).
|
||||||
|
const prepareComposerQuoteHeader = useCallback(async (
|
||||||
|
email: Email | null,
|
||||||
|
mode: 'reply' | 'replyAll' | 'forward',
|
||||||
|
) => {
|
||||||
|
if (!email) { setComposerQuoteHeader(null); return; }
|
||||||
|
try {
|
||||||
|
const replyTargets = (email.replyTo?.length
|
||||||
|
? email.replyTo
|
||||||
|
: email.from ?? []).filter(r => r.email).map(r => r.email!);
|
||||||
|
const newTo = mode === 'reply'
|
||||||
|
? replyTargets
|
||||||
|
: mode === 'replyAll'
|
||||||
|
? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)]
|
||||||
|
: [];
|
||||||
|
const newCc = mode === 'replyAll'
|
||||||
|
? (email.cc ?? []).filter(r => r.email).map(r => r.email!)
|
||||||
|
: [];
|
||||||
|
const header = await buildQuoteHeader({
|
||||||
|
mode,
|
||||||
|
email: {
|
||||||
|
from: email.from,
|
||||||
|
to: email.to,
|
||||||
|
cc: email.cc,
|
||||||
|
subject: email.subject,
|
||||||
|
receivedAt: email.receivedAt,
|
||||||
|
},
|
||||||
|
newTo,
|
||||||
|
newCc,
|
||||||
|
locale: useLocaleStore.getState().locale,
|
||||||
|
timeFormat: useSettingsStore.getState().timeFormat,
|
||||||
|
unknownLabel: tCommon('unknown'),
|
||||||
|
});
|
||||||
|
setComposerQuoteHeader(header);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[quote-header] plugin transform failed; using default', err);
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
|
}
|
||||||
|
}, [tCommon]);
|
||||||
|
|
||||||
const handleReply = async (draftText?: string) => {
|
const handleReply = async (draftText?: string) => {
|
||||||
if (selectedEmail) {
|
if (selectedEmail) {
|
||||||
const ok = await emailHooks.onBeforeReply.intercept({
|
const ok = await emailHooks.onBeforeReply.intercept({
|
||||||
@@ -1007,6 +1131,9 @@ export default function Home() {
|
|||||||
mode: 'reply' as const,
|
mode: 'reply' as const,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await prepareComposerQuoteHeader(selectedEmail, 'reply');
|
||||||
|
} else {
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
setComposerDraftText(draftText || "");
|
setComposerDraftText(draftText || "");
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
@@ -1074,6 +1201,9 @@ export default function Home() {
|
|||||||
mode: 'reply-all' as const,
|
mode: 'reply-all' as const,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await prepareComposerQuoteHeader(selectedEmail, 'replyAll');
|
||||||
|
} else {
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1088,6 +1218,9 @@ export default function Home() {
|
|||||||
mode: 'forward' as const,
|
mode: 'forward' as const,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await prepareComposerQuoteHeader(selectedEmail, 'forward');
|
||||||
|
} else {
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1296,6 +1429,35 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Whenever the global active account changes, drop any non-active viewing
|
||||||
|
// override so we don't leave the email list pointed at a now-stale id.
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewingAccountId && viewingAccountId === activeAccountId) {
|
||||||
|
setViewingAccount(null);
|
||||||
|
}
|
||||||
|
}, [activeAccountId, viewingAccountId, setViewingAccount]);
|
||||||
|
|
||||||
|
// Pro sidebar: user clicked a folder under a specific account group.
|
||||||
|
// accountId === null means the active account; non-null means a viewing
|
||||||
|
// override that fetches via that account's JMAP client.
|
||||||
|
const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => {
|
||||||
|
const viewingClient = accountId
|
||||||
|
? useAuthStore.getState().getClientForAccount(accountId) ?? client
|
||||||
|
: client;
|
||||||
|
selectAccountMailbox(accountId, mailboxId);
|
||||||
|
selectEmail(null);
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
setActiveView("list");
|
||||||
|
}
|
||||||
|
if (isTablet) {
|
||||||
|
setTabletListVisible(true);
|
||||||
|
}
|
||||||
|
if (viewingClient) {
|
||||||
|
await fetchEmails(viewingClient, mailboxId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMailboxSelect = async (mailboxId: string) => {
|
const handleMailboxSelect = async (mailboxId: string) => {
|
||||||
if (isUnifiedMailboxId(mailboxId)) {
|
if (isUnifiedMailboxId(mailboxId)) {
|
||||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||||
@@ -1634,7 +1796,6 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
if (isUnifiedView) return;
|
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (!isFilterEmpty(searchFilters)) {
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
@@ -1646,14 +1807,25 @@ export default function Home() {
|
|||||||
const handleClearSearch = async () => {
|
const handleClearSearch = async () => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
clearSearchFilters();
|
clearSearchFilters();
|
||||||
if (client && selectedMailbox) {
|
if (!client) return;
|
||||||
|
// In unified view the active "mailbox" is a virtual role, so refresh via
|
||||||
|
// the unified fan-out instead of fetchEmails.
|
||||||
|
if (isUnifiedView) {
|
||||||
|
const role = useEmailStore.getState().unifiedRole;
|
||||||
|
if (role) {
|
||||||
|
const built = buildUnifiedAccounts();
|
||||||
|
const populated = await populateUnifiedAccountMailboxes(built);
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedMailbox) {
|
||||||
await fetchEmails(client, selectedMailbox);
|
await fetchEmails(client, selectedMailbox);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdvancedSearch = async () => {
|
const handleAdvancedSearch = async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
if (isUnifiedView) return;
|
|
||||||
await advancedSearch(client);
|
await advancedSearch(client);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1663,9 +1835,9 @@ export default function Home() {
|
|||||||
clearTimeout(advancedSearchDebounceRef.current);
|
clearTimeout(advancedSearchDebounceRef.current);
|
||||||
}
|
}
|
||||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||||
if (client && !isUnifiedView) advancedSearch(client);
|
if (client) advancedSearch(client);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [client, advancedSearch, isUnifiedView]);
|
}, [client, advancedSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1801,7 +1973,7 @@ export default function Home() {
|
|||||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
|
||||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||||
|
|
||||||
// Handle email selection with mobile view switching
|
// Handle email selection with mobile view switching
|
||||||
@@ -1916,22 +2088,25 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle reply from conversation view
|
// Handle reply from conversation view
|
||||||
const handleConversationReply = (email: Email) => {
|
const handleConversationReply = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
|
await prepareComposerQuoteHeader(email, 'reply');
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConversationReplyAll = (email: Email) => {
|
const handleConversationReplyAll = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
|
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConversationForward = (email: Email) => {
|
const handleConversationForward = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
|
await prepareComposerQuoteHeader(email, 'forward');
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -1959,7 +2134,8 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
|
<AppTopBannerSlot />
|
||||||
{isRateLimited && rateLimitSecondsLeft !== null && (
|
{isRateLimited && rateLimitSecondsLeft !== null && (
|
||||||
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
|
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
@@ -1974,8 +2150,8 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Desktop Navigation Rail */}
|
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && !isEmbedded && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
collapsed
|
collapsed
|
||||||
@@ -1995,25 +2171,44 @@ export default function Home() {
|
|||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
|
{/* Mobile/Tablet Sidebar Overlay Backdrop.
|
||||||
|
When embedded in a Pro pane the viewport is desktop-wide, so the
|
||||||
|
`lg:hidden` viewport-variant alone wouldn't gate this overlay;
|
||||||
|
scope to the pane via `absolute` so the backdrop stays inside
|
||||||
|
the pane instead of covering the whole window. */}
|
||||||
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
|
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
className={cn(
|
||||||
|
"inset-0 bg-black/50 z-40",
|
||||||
|
isEmbedded ? "absolute" : "fixed lg:hidden"
|
||||||
|
)}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
{/* Sidebar - overlay on mobile/tablet, in-flow on desktop.
|
||||||
|
When embedded, overlay-mode is driven by pane-aware JS rather
|
||||||
|
than viewport-variants (which still see the full window). */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 h-full z-50",
|
"flex-shrink-0 h-full z-50",
|
||||||
!isResizing && "transition-[width] duration-300",
|
!isResizing && "transition-[width] duration-300",
|
||||||
// Mobile/Tablet: fixed overlay
|
isEmbedded
|
||||||
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
|
? (isMobile || isTablet
|
||||||
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
|
? cn(
|
||||||
!sidebarOpen && "max-lg:-translate-x-full",
|
"absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
|
||||||
// Desktop: normal flow
|
"transform transition-transform duration-300 ease-in-out",
|
||||||
"lg:relative lg:translate-x-0",
|
!sidebarOpen && "-translate-x-full"
|
||||||
|
)
|
||||||
|
: "relative translate-x-0")
|
||||||
|
: cn(
|
||||||
|
// Mobile/Tablet: fixed overlay
|
||||||
|
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
|
||||||
|
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
|
||||||
|
!sidebarOpen && "max-lg:-translate-x-full",
|
||||||
|
// Desktop: normal flow
|
||||||
|
"lg:relative lg:translate-x-0"
|
||||||
|
),
|
||||||
inlineApp && "hidden"
|
inlineApp && "hidden"
|
||||||
)}
|
)}
|
||||||
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
|
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
|
||||||
@@ -2045,6 +2240,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSidebarClose={() => setSidebarOpen(false)}
|
onSidebarClose={() => setSidebarOpen(false)}
|
||||||
|
multiAccountMode={isEmbedded}
|
||||||
|
accountMailboxes={accountMailboxes}
|
||||||
|
viewingAccountId={viewingAccountId}
|
||||||
|
onAccountMailboxSelect={handleAccountMailboxSelect}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@@ -2137,8 +2336,6 @@ export default function Home() {
|
|||||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
disabled={isUnifiedView}
|
|
||||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -2154,15 +2351,13 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
disabled={isUnifiedView}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||||
isUnifiedView && "opacity-50 cursor-not-allowed",
|
|
||||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
title={t("advanced_search.toggle_filters")}
|
||||||
>
|
>
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
@@ -2321,6 +2516,14 @@ export default function Home() {
|
|||||||
selectedEmailId={selectedEmail?.id}
|
selectedEmailId={selectedEmail?.id}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onEmailSelect={handleEmailSelect}
|
onEmailSelect={handleEmailSelect}
|
||||||
|
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||||
|
useProTabStore.getState().openEmailTab({
|
||||||
|
accountId: email.accountId ?? '',
|
||||||
|
emailId: email.id,
|
||||||
|
mailboxId: selectedMailbox,
|
||||||
|
title: email.subject?.trim() || t('email_composer.new_message'),
|
||||||
|
});
|
||||||
|
}) : undefined}
|
||||||
onOpenConversation={handleOpenConversation}
|
onOpenConversation={handleOpenConversation}
|
||||||
// Context menu handlers
|
// Context menu handlers
|
||||||
onReply={(email) => {
|
onReply={(email) => {
|
||||||
@@ -2393,7 +2596,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email list resize handle (desktop only) */}
|
{/* Email list resize handle (desktop only) */}
|
||||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||||
@@ -2418,7 +2621,7 @@ export default function Home() {
|
|||||||
isHorizontalMailLayout ? "min-h-0" : "h-full",
|
isHorizontalMailLayout ? "min-h-0" : "h-full",
|
||||||
// Mobile: full screen overlay when active
|
// Mobile: full screen overlay when active
|
||||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||||
"max-md:h-full",
|
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
|
||||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||||
// Tablet/Desktop: relative
|
// Tablet/Desktop: relative
|
||||||
"md:relative",
|
"md:relative",
|
||||||
@@ -2426,13 +2629,16 @@ export default function Home() {
|
|||||||
shouldHideHorizontalViewerPane && "md:hidden"
|
shouldHideHorizontalViewerPane && "md:hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Inline Composer - shown in viewer pane */}
|
{/* Inline Composer - shown in viewer pane.
|
||||||
{showComposer ? (
|
In Pro/embedded mode the composer is hoisted into its own
|
||||||
|
Pro tab (see the effect below), so we never render it inline. */}
|
||||||
|
{(showComposer && !isEmbedded) ? (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={ComposerErrorFallback}
|
fallback={ComposerErrorFallback}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmailComposer
|
<EmailComposer
|
||||||
@@ -2452,6 +2658,9 @@ export default function Home() {
|
|||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
inReplyTo: selectedEmail.inReplyTo,
|
inReplyTo: selectedEmail.inReplyTo,
|
||||||
references: selectedEmail.references,
|
references: selectedEmail.references,
|
||||||
|
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||||
|
quoteHeaderText: composerQuoteHeader?.text,
|
||||||
|
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||||
} : undefined)}
|
} : undefined)}
|
||||||
initialDraftText={composerDraftText}
|
initialDraftText={composerDraftText}
|
||||||
initialData={pendingDraft}
|
initialData={pendingDraft}
|
||||||
@@ -2471,6 +2680,7 @@ export default function Home() {
|
|||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setComposerDraftText("");
|
setComposerDraftText("");
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setActiveView('list');
|
setActiveView('list');
|
||||||
}
|
}
|
||||||
@@ -2584,8 +2794,8 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Navigation - mobile and tablet */}
|
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
|
||||||
{(isMobile || isTablet) && activeView !== "viewer" && (
|
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
onManageApps={handleManageApps}
|
onManageApps={handleManageApps}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
|
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||||
|
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||||
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
|
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
|
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||||
|
import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||||
|
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
|
||||||
|
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import MailPage from "@/app/(main)/[locale]/page";
|
||||||
|
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
|
||||||
|
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
|
||||||
|
import FilesPage from "@/app/(main)/[locale]/files/page";
|
||||||
|
import SettingsPage from "@/app/(main)/[locale]/settings/page";
|
||||||
|
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
||||||
|
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
||||||
|
|
||||||
|
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
|
||||||
|
mail: MailPage,
|
||||||
|
calendar: CalendarPage,
|
||||||
|
contacts: ContactsPage,
|
||||||
|
files: FilesPage,
|
||||||
|
settings: SettingsPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
type DropTarget = 'left' | 'right' | null;
|
||||||
|
|
||||||
|
function renderTabBody(tab: ProTab): React.ReactNode {
|
||||||
|
if (tab.kind === 'compose' && tab.composeData) {
|
||||||
|
return <ProComposeTabBody tabId={tab.id} data={tab.composeData} />;
|
||||||
|
}
|
||||||
|
if (tab.kind === 'email' && tab.emailData) {
|
||||||
|
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
|
||||||
|
}
|
||||||
|
const Component = APP_TAB_COMPONENTS[tab.kind];
|
||||||
|
return Component ? <Component /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneProps {
|
||||||
|
paneId: ProPaneId;
|
||||||
|
tabs: ProTab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
loadedTabIds: string[];
|
||||||
|
onPaneFocus: (paneId: ProPaneId) => void;
|
||||||
|
isFocused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
|
||||||
|
const paneRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Measured pane width, published to children via PaneSizeContext so that
|
||||||
|
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
|
||||||
|
// viewport - and inner pages collapse to their mobile/tablet layouts when
|
||||||
|
// the pane is narrow.
|
||||||
|
const [paneWidth, setPaneWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = paneRef.current;
|
||||||
|
if (!el || typeof ResizeObserver === "undefined") return;
|
||||||
|
const initialRect = el.getBoundingClientRect();
|
||||||
|
if (initialRect.width > 0) setPaneWidth(initialRect.width);
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) return;
|
||||||
|
const w = entry.contentRect.width;
|
||||||
|
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={paneRef}
|
||||||
|
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
|
||||||
|
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
|
||||||
|
>
|
||||||
|
<PaneSizeContext.Provider value={paneWidth}>
|
||||||
|
{tabs
|
||||||
|
.filter((tab) => loadedTabIds.includes(tab.id))
|
||||||
|
.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
>
|
||||||
|
{renderTabBody(tab)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PaneSizeContext.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProHome() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||||
|
|
||||||
|
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||||
|
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
|
||||||
|
);
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
const {
|
||||||
|
showAppsModal,
|
||||||
|
inlineApp,
|
||||||
|
loadedApps,
|
||||||
|
handleManageApps,
|
||||||
|
handleInlineApp,
|
||||||
|
closeInlineApp,
|
||||||
|
closeAppsModal,
|
||||||
|
} = useSidebarApps();
|
||||||
|
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const client = useAuthStore((s) => s.client);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||||
|
const authLoading = useAuthStore((s) => s.isLoading);
|
||||||
|
const quota = useEmailStore((s) => s.quota);
|
||||||
|
const isPushConnected = useEmailStore((s) => s.isPushConnected);
|
||||||
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
|
||||||
|
const tabs = useProTabStore((s) => s.tabs);
|
||||||
|
const activeMainTabId = useProTabStore((s) => s.activeTabId);
|
||||||
|
const activeSplitTabId = useProTabStore((s) => s.activeSplitTabId);
|
||||||
|
const splitOrientation = useProTabStore((s) => s.splitOrientation);
|
||||||
|
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||||
|
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
|
||||||
|
const openTab = useProTabStore((s) => s.openTab);
|
||||||
|
const closeTab = useProTabStore((s) => s.closeTab);
|
||||||
|
const setActiveTab = useProTabStore((s) => s.setActiveTab);
|
||||||
|
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
|
||||||
|
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
|
||||||
|
|
||||||
|
const [isTabDragging, setIsTabDragging] = useState(false);
|
||||||
|
const [splitDropTarget, setSplitDropTarget] = useState<DropTarget>(null);
|
||||||
|
/** Whether the split pane visually renders before (true) or after (false) main. */
|
||||||
|
const [splitLeading, setSplitLeading] = useState(false);
|
||||||
|
|
||||||
|
// Auth bootstrap (mirrors standard page)
|
||||||
|
useEffect(() => {
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (state.isAuthenticated && state.client) {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkAuth().finally(() => {
|
||||||
|
setInitialCheckDone(true);
|
||||||
|
});
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialCheckDone || typeof window === "undefined") return;
|
||||||
|
// Pro is desktop-only, and only used when the user has explicitly
|
||||||
|
// enabled it. If either precondition stops holding, hand the user back
|
||||||
|
// to the standard shell.
|
||||||
|
if (isMobile || isTablet || !proInterface) {
|
||||||
|
window.location.replace("/");
|
||||||
|
}
|
||||||
|
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||||
|
|
||||||
|
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
|
||||||
|
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
|
||||||
|
|
||||||
|
const focusedActiveTab = useMemo(() => {
|
||||||
|
const id = focusedPaneId === 'main' ? activeMainTabId : activeSplitTabId;
|
||||||
|
return tabs.find((t) => t.id === id) ?? null;
|
||||||
|
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
|
||||||
|
|
||||||
|
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
|
||||||
|
openTab(itemId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const railActiveItemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null =
|
||||||
|
focusedActiveTab && (
|
||||||
|
focusedActiveTab.kind === 'mail' || focusedActiveTab.kind === 'calendar'
|
||||||
|
|| focusedActiveTab.kind === 'contacts' || focusedActiveTab.kind === 'files'
|
||||||
|
|| focusedActiveTab.kind === 'settings'
|
||||||
|
) ? focusedActiveTab.kind : null;
|
||||||
|
|
||||||
|
const isSplit = splitOrientation !== null && splitTabs.length > 0;
|
||||||
|
|
||||||
|
// ---- Body-level drop targets ----
|
||||||
|
|
||||||
|
const isProTabDrag = (e: DragEvent) => e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
|
||||||
|
|
||||||
|
const computeDropTarget = (e: DragEvent<HTMLDivElement>): DropTarget => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const xFrac = (e.clientX - rect.left) / rect.width;
|
||||||
|
return xFrac < 0.5 ? 'left' : 'right';
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetPaneFromDrop = (target: DropTarget): ProPaneId | null => {
|
||||||
|
if (!target || !isSplit) return null;
|
||||||
|
const leftIsSplit = splitLeading;
|
||||||
|
if (target === 'left') return leftIsSplit ? 'split' : 'main';
|
||||||
|
return leftIsSplit ? 'main' : 'split';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
const next = computeDropTarget(e);
|
||||||
|
if (next !== splitDropTarget) setSplitDropTarget(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
const next = e.relatedTarget as Node | null;
|
||||||
|
if (next && e.currentTarget.contains(next)) return;
|
||||||
|
setSplitDropTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBodyDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!isProTabDrag(e)) return;
|
||||||
|
const target = computeDropTarget(e);
|
||||||
|
setSplitDropTarget(null);
|
||||||
|
setIsTabDragging(false);
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||||
|
if (!draggedId) return;
|
||||||
|
|
||||||
|
if (isSplit) {
|
||||||
|
// Move tab to whichever pane occupies the dropped side.
|
||||||
|
const destPane = targetPaneFromDrop(target);
|
||||||
|
if (destPane) moveTabToPane(draggedId, destPane);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Create a new side-by-side split. `splitLeading` controls which side
|
||||||
|
// visually hosts the split pane.
|
||||||
|
moveTabToPane(draggedId, 'split', 'vertical');
|
||||||
|
setSplitLeading(target === 'left');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state (matches standard page exactly)
|
||||||
|
if (!initialCheckDone || authLoading || !isAuthenticated || !client) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDesktop) return null;
|
||||||
|
|
||||||
|
// Stable keys are essential: when the split collapses, the row's child
|
||||||
|
// list goes from [splitPane, divider, mainPane] (or the leading variant)
|
||||||
|
// to [mainPane]. Without keys, React would reuse the Pane instance at
|
||||||
|
// index 0 - repurposing the *split* pane's instance into the main pane,
|
||||||
|
// which strands the main pane's ResizeObserver/paneWidth on a now-
|
||||||
|
// unmounted DOM node and reparents the mail tab body (causing remount
|
||||||
|
// + stale "still-narrow" measurements after the split is closed).
|
||||||
|
const mainPane = (
|
||||||
|
<Pane
|
||||||
|
key="pane-main"
|
||||||
|
paneId="main"
|
||||||
|
tabs={mainTabs}
|
||||||
|
activeTabId={activeMainTabId}
|
||||||
|
loadedTabIds={loadedTabIds}
|
||||||
|
onPaneFocus={setFocusedPane}
|
||||||
|
isFocused={focusedPaneId === 'main'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitPane = isSplit ? (
|
||||||
|
<Pane
|
||||||
|
key="pane-split"
|
||||||
|
paneId="split"
|
||||||
|
tabs={splitTabs}
|
||||||
|
activeTabId={activeSplitTabId}
|
||||||
|
loadedTabIds={loadedTabIds}
|
||||||
|
onPaneFocus={setFocusedPane}
|
||||||
|
isFocused={focusedPaneId === 'split'}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const splitDivider = isSplit ? (
|
||||||
|
<div
|
||||||
|
key="pane-divider"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-shrink-0 w-px bg-transparent"
|
||||||
|
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Drop-zone overlay: a single half-body preview of where the dragged tab
|
||||||
|
// would land. The whole body is always a drop target (the entire surface
|
||||||
|
// maps to one of the four sides), so we only render the active side.
|
||||||
|
const dropZone = isTabDragging && splitDropTarget ? (
|
||||||
|
<DropZone side={splitDropTarget} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddedContext.Provider value={true}>
|
||||||
|
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Leftmost Navigation Rail - identical to the standard layout */}
|
||||||
|
<div
|
||||||
|
className="w-14 bg-secondary flex flex-col flex-shrink-0"
|
||||||
|
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||||
|
>
|
||||||
|
<NavigationRail
|
||||||
|
collapsed
|
||||||
|
quota={quota}
|
||||||
|
isPushConnected={isPushConnected}
|
||||||
|
onLogout={logout}
|
||||||
|
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||||
|
onManageApps={handleManageApps}
|
||||||
|
onInlineApp={handleInlineApp}
|
||||||
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
onNavigate={handleRailNavigate}
|
||||||
|
activeItemId={railActiveItemId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inlineApp && (
|
||||||
|
<InlineAppView
|
||||||
|
apps={loadedApps}
|
||||||
|
activeAppId={inlineApp.id}
|
||||||
|
onClose={closeInlineApp}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inlineApp && (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
|
||||||
|
{/* Single, unified tab bar above both panes. */}
|
||||||
|
<ProTabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeMainTabId={activeMainTabId}
|
||||||
|
activeSplitTabId={activeSplitTabId}
|
||||||
|
onActivate={setActiveTab}
|
||||||
|
onClose={closeTab}
|
||||||
|
onDragStateChange={setIsTabDragging}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panes container - accepts body drops for split/move. */}
|
||||||
|
<div
|
||||||
|
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
|
||||||
|
onDragOver={handleBodyDragOver}
|
||||||
|
onDragLeave={handleBodyDragLeave}
|
||||||
|
onDrop={handleBodyDrop}
|
||||||
|
>
|
||||||
|
{isSplit
|
||||||
|
? (splitLeading
|
||||||
|
? <>{splitPane}{splitDivider}{mainPane}</>
|
||||||
|
: <>{mainPane}{splitDivider}{splitPane}</>)
|
||||||
|
: mainPane}
|
||||||
|
|
||||||
|
{dropZone}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
|
{showAppsModal && (
|
||||||
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</EmbeddedContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropZone({ side }: { side: 'left' | 'right' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute top-0 bottom-0 w-1/2 z-10",
|
||||||
|
"bg-primary/15 ring-2 ring-primary/40 ring-inset",
|
||||||
|
side === 'left' ? "left-0" : "right-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||||
|
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
|
||||||
import { LayoutSettings } from '@/components/settings/layout-settings';
|
import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||||
@@ -75,6 +76,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
|
|||||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||||
|
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
@@ -159,6 +161,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
'settings.account.email',
|
'settings.account.email',
|
||||||
'settings.account.server',
|
'settings.account.server',
|
||||||
'settings.account.storage',
|
'settings.account.storage',
|
||||||
|
'settings.account.accounts',
|
||||||
],
|
],
|
||||||
language: ['settings.appearance.language'],
|
language: ['settings.appearance.language'],
|
||||||
notifications: ['settings.notifications'],
|
notifications: ['settings.notifications'],
|
||||||
@@ -226,7 +229,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
|||||||
// Extra English keywords per tab so common search terms hit even when the
|
// Extra English keywords per tab so common search terms hit even when the
|
||||||
// translation doesn't contain the literal word.
|
// translation doesn't contain the literal word.
|
||||||
const tabKeywords: Record<Tab, string> = {
|
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',
|
language: 'locale region timezone date time format',
|
||||||
notifications: 'sound alert push badge',
|
notifications: 'sound alert push badge',
|
||||||
appearance: 'theme dark light font size accent color animation density',
|
appearance: 'theme dark light font size accent color animation density',
|
||||||
@@ -345,6 +348,7 @@ export default function SettingsPage() {
|
|||||||
const tSidebar = useTranslations('sidebar');
|
const tSidebar = useTranslations('sidebar');
|
||||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
@@ -359,6 +363,7 @@ export default function SettingsPage() {
|
|||||||
const installedPlugins = usePluginStore((s) => s.plugins);
|
const installedPlugins = usePluginStore((s) => s.plugins);
|
||||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
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
|
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||||
// (individual settings) per tab. Sub-results come from translation entries
|
// (individual settings) per tab. Sub-results come from translation entries
|
||||||
@@ -589,8 +594,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
...(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' },
|
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||||
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, 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 }] : []),
|
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
@@ -686,7 +691,8 @@ export default function SettingsPage() {
|
|||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
if (mobileShowContent) {
|
if (mobileShowContent) {
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -703,20 +709,23 @@ export default function SettingsPage() {
|
|||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavigationRail
|
{!isEmbedded && (
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -812,13 +821,15 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavigationRail
|
{!isEmbedded && (
|
||||||
orientation="horizontal"
|
<NavigationRail
|
||||||
onManageApps={handleManageApps}
|
orientation="horizontal"
|
||||||
onInlineApp={handleInlineApp}
|
onManageApps={handleManageApps}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onInlineApp={handleInlineApp}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onCloseInlineApp={closeInlineApp}
|
||||||
/>
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -826,19 +837,23 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
// Desktop layout
|
// Desktop layout
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background">
|
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<AppTopBannerSlot />
|
||||||
<NavigationRail
|
<div className="flex flex-1 min-h-0">
|
||||||
collapsed
|
{!isEmbedded && (
|
||||||
quota={quota}
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
isPushConnected={isPushConnected}
|
<NavigationRail
|
||||||
onLogout={logout}
|
collapsed
|
||||||
onManageApps={handleManageApps}
|
quota={quota}
|
||||||
onInlineApp={handleInlineApp}
|
isPushConnected={isPushConnected}
|
||||||
onCloseInlineApp={closeInlineApp}
|
onLogout={logout}
|
||||||
activeAppId={inlineApp?.id ?? null}
|
onManageApps={handleManageApps}
|
||||||
/>
|
onInlineApp={handleInlineApp}
|
||||||
</div>
|
onCloseInlineApp={closeInlineApp}
|
||||||
|
activeAppId={inlineApp?.id ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{inlineApp && (
|
{inlineApp && (
|
||||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||||
@@ -852,17 +867,19 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
style={{ width: `${settingsSidebarWidth}px` }}
|
style={{ width: `${settingsSidebarWidth}px` }}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-border">
|
{!proInterface && (
|
||||||
<Button
|
<div className="p-4 border-b border-border">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => router.push('/')}
|
size="sm"
|
||||||
className="w-full justify-start"
|
onClick={() => router.push('/')}
|
||||||
>
|
className="w-full justify-start"
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
>
|
||||||
{t('back_to_mail')}
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
{t('back_to_mail')}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||||
<div className="px-3 pt-1 pb-1">
|
<div className="px-3 pt-1 pb-1">
|
||||||
@@ -958,6 +975,7 @@ export default function SettingsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,12 @@ import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
|
||||||
|
// `value` omitted and `hasValue` set instead - the server never echoes
|
||||||
|
// the raw secret to the client.
|
||||||
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthTab() {
|
export function AuthTab() {
|
||||||
@@ -267,8 +271,10 @@ 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 Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
|
||||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||||
|
<Text label="OAuth Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||||
|
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Single Sign-On">
|
<Section title="Single Sign-On">
|
||||||
@@ -5,8 +5,9 @@ import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-reac
|
|||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_FIELDS = [
|
const IMAGE_FIELDS = [
|
||||||
@@ -24,6 +25,20 @@ const TEXT_FIELDS = [
|
|||||||
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PWA_IMAGE_FIELDS = [
|
||||||
|
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PWA_COLOR_FIELDS = [
|
||||||
|
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
|
||||||
|
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
|
||||||
|
];
|
||||||
|
|
||||||
export function BrandingTab() {
|
export function BrandingTab() {
|
||||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||||
@@ -261,6 +276,142 @@ export function BrandingTab() {
|
|||||||
</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>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
{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.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"
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
{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="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"
|
||||||
|
/>
|
||||||
|
{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 className="border border-border rounded-lg">
|
<div className="border border-border rounded-lg">
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||||
@@ -26,7 +26,7 @@ export function DashboardTab() {
|
|||||||
const [status, setStatus] = useState<AdminStatus | null>(null);
|
const [status, setStatus] = useState<AdminStatus | null>(null);
|
||||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
@@ -96,7 +96,9 @@ export function DashboardTab() {
|
|||||||
const sources = await adminConfigRes.json();
|
const sources = await adminConfigRes.json();
|
||||||
setConfigSources(sources);
|
setConfigSources(sources);
|
||||||
const sessionSecret = sources?.sessionSecret;
|
const sessionSecret = sources?.sessionSecret;
|
||||||
if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') {
|
// Server redacts the raw value for sensitive keys; rely on hasValue,
|
||||||
|
// which is false when unset or matching a known placeholder default.
|
||||||
|
if (!sessionSecret?.hasValue) {
|
||||||
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
|
||||||
}
|
}
|
||||||
const adminPassword = sources?.adminPassword;
|
const adminPassword = sources?.adminPassword;
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react';
|
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||||
|
|
||||||
|
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||||
|
|
||||||
interface Extension {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -18,6 +21,7 @@ interface Extension {
|
|||||||
minAppVersion: string | null;
|
minAppVersion: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
installedVersion: string | null;
|
||||||
iconUrl: string | null;
|
iconUrl: string | null;
|
||||||
bannerUrl: string | null;
|
bannerUrl: string | null;
|
||||||
author: {
|
author: {
|
||||||
@@ -94,6 +98,15 @@ export function MarketplaceTab() {
|
|||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
async function handleInstall(ext: Extension) {
|
async function handleInstall(ext: Extension) {
|
||||||
|
if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isUpdate = ext.installed;
|
||||||
|
const targetVersion = ext.latestVersion || '1.0.0';
|
||||||
setInstalling(ext.slug);
|
setInstalling(ext.slug);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
@@ -103,7 +116,7 @@ export function MarketplaceTab() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slug: ext.slug,
|
slug: ext.slug,
|
||||||
version: ext.latestVersion || '1.0.0',
|
version: targetVersion,
|
||||||
type: ext.type,
|
type: ext.type,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -112,13 +125,22 @@ export function MarketplaceTab() {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
setMessage({
|
||||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
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 {
|
} else {
|
||||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(null);
|
setInstalling(null);
|
||||||
}
|
}
|
||||||
@@ -258,6 +280,13 @@ function ExtensionCard({
|
|||||||
}) {
|
}) {
|
||||||
const isPlugin = extension.type === 'plugin';
|
const isPlugin = extension.type === 'plugin';
|
||||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
const versionMismatch = !!extension.minAppVersion
|
||||||
|
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||||
|
const updateAvailable = extension.installed
|
||||||
|
&& !!extension.installedVersion
|
||||||
|
&& !!extension.latestVersion
|
||||||
|
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
|
||||||
|
&& !versionMismatch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
@@ -346,12 +375,37 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="px-4 pb-4 -mt-1">
|
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||||
{extension.installed ? (
|
{extension.installed && updateAvailable ? (
|
||||||
<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">
|
<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" />
|
<Check className="w-3 h-3" />
|
||||||
Installed
|
Installed
|
||||||
</span>
|
</span>
|
||||||
|
) : versionMismatch ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
|
||||||
|
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Requires v{extension.minAppVersion}+
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
+23
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||||
|
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||||
|
|
||||||
interface ConfigField {
|
interface ConfigField {
|
||||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||||
@@ -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>
|
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PluginAdminSection pluginId={pluginId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
|
||||||
|
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
|
||||||
|
* and the host gives it a dedicated iframe inside the admin panel.
|
||||||
|
*/
|
||||||
|
function PluginAdminSection({ pluginId }: { pluginId: string }) {
|
||||||
|
const offers = usePluginSlotOffers('admin-plugin-page');
|
||||||
|
const offer = offers.find((o) => o.pluginId === pluginId);
|
||||||
|
if (!offer) return null;
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Plugin admin panel
|
||||||
|
</div>
|
||||||
|
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,8 +7,9 @@ import { JmapServersSection } from './_jmap-servers-section';
|
|||||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
|
hasValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsTab() {
|
export function SettingsTab() {
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
const logoUrl = resolvedTheme === 'dark'
|
const logoUrl = resolvedTheme === 'dark'
|
||||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||||
@@ -179,7 +181,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
// /admin lives outside the [locale] tree, so links back to the webmail
|
// /admin lives outside the [locale] tree, so links back to the webmail
|
||||||
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
|
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
|
||||||
// basePath to <Link>/router APIs — for these we prepend it manually so
|
// basePath to <Link>/router APIs - for these we prepend it manually so
|
||||||
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
|
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
|
||||||
const prefix = getPathPrefix();
|
const prefix = getPathPrefix();
|
||||||
|
|
||||||
@@ -300,13 +302,15 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
>
|
>
|
||||||
<BookUser className="w-[18px] h-[18px]" />
|
<BookUser className="w-[18px] h-[18px]" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
{filesEnabled && (
|
||||||
href={`${prefix}/files`}
|
<a
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
href={`${prefix}/files`}
|
||||||
title="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>
|
<HardDrive className="w-[18px] h-[18px]" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<div className="mt-auto flex flex-col items-center gap-2">
|
<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">
|
<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]" />
|
<Shield className="w-[18px] h-[18px]" />
|
||||||
@@ -440,14 +444,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<BookUser className="w-5 h-5" />
|
<BookUser className="w-5 h-5" />
|
||||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
{filesEnabled && (
|
||||||
href={`${prefix}/files`}
|
<a
|
||||||
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"
|
href={`${prefix}/files`}
|
||||||
title="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>
|
<HardDrive className="w-5 h-5" />
|
||||||
</a>
|
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<div
|
<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"
|
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"
|
title="Admin"
|
||||||
+64
-9
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowUpCircle,
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -21,6 +22,9 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||||
|
|
||||||
|
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||||
|
|
||||||
interface PreviewData {
|
interface PreviewData {
|
||||||
extension: {
|
extension: {
|
||||||
@@ -70,6 +74,7 @@ interface PreviewData {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
installedVersion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RISKY_PERMISSIONS = new Set([
|
const RISKY_PERMISSIONS = new Set([
|
||||||
@@ -115,6 +120,8 @@ export default function MarketplacePreviewPage() {
|
|||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
const isUpdate = data.installed;
|
||||||
|
const targetVersion = data.extension.latestVersion || '1.0.0';
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
@@ -123,20 +130,25 @@ export default function MarketplacePreviewPage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slug: data.extension.slug,
|
slug: data.extension.slug,
|
||||||
version: data.extension.latestVersion || '1.0.0',
|
version: targetVersion,
|
||||||
type: data.extension.type,
|
type: data.extension.type,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||||
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
setMessage({
|
||||||
setData(prev => prev ? { ...prev, installed: true } : prev);
|
type: 'success',
|
||||||
|
text: isUpdate
|
||||||
|
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
|
||||||
|
: `"${data.extension.name}" installed${warnings}`,
|
||||||
|
});
|
||||||
|
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||||
} finally {
|
} finally {
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
}
|
}
|
||||||
@@ -200,6 +212,12 @@ export default function MarketplacePreviewPage() {
|
|||||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||||
|
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||||
|
const updateAvailable = data.installed
|
||||||
|
&& !!data.installedVersion
|
||||||
|
&& !!ext.latestVersion
|
||||||
|
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
|
||||||
|
&& !versionMismatch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
@@ -244,11 +262,22 @@ export default function MarketplacePreviewPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<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>
|
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||||
{data.installed && (
|
{data.installed && !updateAvailable && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
|
||||||
|
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
|
||||||
|
>
|
||||||
<Check className="w-3 h-3" /> Installed
|
<Check className="w-3 h-3" /> Installed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{data.installed && updateAvailable && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
|
||||||
|
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="w-3 h-3" /> Update available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
@@ -275,6 +304,17 @@ export default function MarketplacePreviewPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
{data.installed ? (
|
{data.installed ? (
|
||||||
<>
|
<>
|
||||||
|
{updateAvailable && (
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !!bundle.error}
|
||||||
|
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
|
||||||
|
Update to v{ext.latestVersion}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||||
@@ -294,8 +334,11 @@ export default function MarketplacePreviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
disabled={installing || !!bundle.error}
|
disabled={installing || !!bundle.error || versionMismatch}
|
||||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
title={versionMismatch
|
||||||
|
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
|
||||||
|
: undefined}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
Install
|
Install
|
||||||
@@ -310,6 +353,18 @@ export default function MarketplacePreviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{versionMismatch && (
|
||||||
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Update Bulwark to install this extension</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-90">
|
||||||
|
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{bundle.error && (
|
{bundle.error && (
|
||||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
import "./globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -17,6 +17,12 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock } from 'lucide-react';
|
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||||
@@ -101,6 +101,14 @@ export default function SetupWizardPage() {
|
|||||||
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
|
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [completed, setCompleted] = useState(false);
|
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 ────────────────────────────────────────────────
|
// ─── Initial status load ────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,6 +181,10 @@ export default function SetupWizardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render shell ───────────────────────────────────────────────────────
|
// ─── Render shell ───────────────────────────────────────────────────────
|
||||||
|
if (insecureContext && !insecureAcknowledged) {
|
||||||
|
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (bootstrapping) {
|
if (bootstrapping) {
|
||||||
return <CenteredCard><p className="text-muted-foreground">Loading…</p></CenteredCard>;
|
return <CenteredCard><p className="text-muted-foreground">Loading…</p></CenteredCard>;
|
||||||
}
|
}
|
||||||
@@ -229,7 +241,7 @@ export default function SetupWizardPage() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = humanError(e);
|
const msg = humanError(e);
|
||||||
setError(msg);
|
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
|
// welcome step so they can re-enter the token without
|
||||||
// having to refresh.
|
// having to refresh.
|
||||||
if (/wizard session required/i.test(msg)) {
|
if (/wizard session required/i.test(msg)) {
|
||||||
@@ -241,7 +253,7 @@ export default function SetupWizardPage() {
|
|||||||
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
|
onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
|
||||||
onFinish={() => {
|
onFinish={() => {
|
||||||
setCompleted(true);
|
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
|
// to see the success screen and works around any router
|
||||||
// edge cases that swallow client-side replaces after the
|
// edge cases that swallow client-side replaces after the
|
||||||
// setupComplete flag flips.
|
// setupComplete flag flips.
|
||||||
@@ -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() {
|
function AlreadyConfiguredScreen() {
|
||||||
return (
|
return (
|
||||||
<CenteredCard>
|
<CenteredCard>
|
||||||
@@ -536,7 +586,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
let entry: { status: ProbeStatus; message: string; url: string };
|
let entry: { status: ProbeStatus; message: string; url: string };
|
||||||
if (data.status === 'jmap_detected') {
|
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') {
|
} 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 };
|
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') {
|
} else if (data.status === 'invalid_url') {
|
||||||
@@ -618,7 +668,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
}
|
}
|
||||||
if (!result) return;
|
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') {
|
if (result.status === 'invalid_url' || result.status === 'unreachable') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -685,7 +735,22 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
This URL uses plain HTTP.
|
This URL uses plain HTTP.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,7 +785,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
|||||||
onChange={(e) => setConfirmedNonJmap(e.target.checked)}
|
onChange={(e) => setConfirmedNonJmap(e.target.checked)}
|
||||||
className="h-4 w-4"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1105,7 +1170,7 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
<form onSubmit={handle} className="space-y-4">
|
<form onSubmit={handle} className="space-y-4">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
title="Branding"
|
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">
|
<Field label="Company / organization name">
|
||||||
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
|
<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,
|
* 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
|
* 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
|
* 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({
|
function BrandingAsset({
|
||||||
label,
|
label,
|
||||||
@@ -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">
|
<div className="flex justify-between items-baseline gap-3 text-sm">
|
||||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1729,6 +1794,57 @@ function isInsecureHttpUrl(url: string): boolean {
|
|||||||
return /^http:\/\//i.test(url.trim());
|
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 {
|
function humanError(e: unknown): string {
|
||||||
if (e instanceof Error) return e.message;
|
if (e instanceof Error) return e.message;
|
||||||
if (typeof e === 'string') return e;
|
if (typeof e === 'string') return e;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import '../globals.css';
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Plugin sandbox',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
style={{ margin: 0, padding: 0, background: 'transparent' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||||
|
|||||||
Binary file not shown.
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||||||
/** Slots that correspond to branding config keys */
|
/** Slots that correspond to branding config keys */
|
||||||
const VALID_SLOTS = new Set([
|
const VALID_SLOTS = new Set([
|
||||||
'faviconUrl',
|
'faviconUrl',
|
||||||
|
'pwaIconUrl',
|
||||||
'appLogoLightUrl',
|
'appLogoLightUrl',
|
||||||
'appLogoDarkUrl',
|
'appLogoDarkUrl',
|
||||||
'loginLogoLightUrl',
|
'loginLogoLightUrl',
|
||||||
@@ -44,7 +45,7 @@ function sanitizeFilename(name: string): string {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -114,7 +115,7 @@ export async function POST(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -2,22 +2,45 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Strings that count as "no real secret configured" - used so the dashboard
|
||||||
|
// can warn about a placeholder session secret without us ever returning the
|
||||||
|
// raw value to the client.
|
||||||
|
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||||
|
*
|
||||||
|
* Sensitive keys (sessionSecret, oauthClientSecret) are returned with
|
||||||
|
* `value` omitted and a `hasValue` boolean instead. An admin session is
|
||||||
|
* enough to read every other config knob; the secrets themselves stay on
|
||||||
|
* the server so that an XSS or session-theft can't lift them in one
|
||||||
|
* request and forge admin/user session cookies offline.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const config = configManager.getAllWithSources();
|
const config = configManager.getAllWithSources();
|
||||||
|
|
||||||
return NextResponse.json(config, {
|
const safe: Record<string, { value?: unknown; source: 'admin' | 'env' | 'default'; hasValue?: boolean }> = {};
|
||||||
|
for (const [key, entry] of Object.entries(config)) {
|
||||||
|
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||||
|
const v = entry.value;
|
||||||
|
const hasValue =
|
||||||
|
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
|
||||||
|
safe[key] = { source: entry.source, hasValue };
|
||||||
|
} else {
|
||||||
|
safe[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(safe, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -31,7 +54,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -86,7 +109,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import {
|
|||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
async function getDirectoryUrl(): Promise<string> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||||
|
|
||||||
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
|||||||
* Lets admins audit what they're about to install before pressing the button.
|
* Lets admins audit what they're about to install before pressing the button.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ slug: string }> },
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const directoryUrl = await getDirectoryUrl();
|
||||||
|
|
||||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
|
||||||
const detailRes = await fetch(detailUrl.toString(), {
|
const detailRes = await fetch(detailUrl.toString(), {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
@@ -63,7 +68,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const bundleUrl = new URL(
|
const bundleUrl = new URL(
|
||||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||||
DIRECTORY_URL,
|
directoryUrl,
|
||||||
);
|
);
|
||||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
@@ -144,14 +149,16 @@ export async function GET(
|
|||||||
getPluginRegistry(),
|
getPluginRegistry(),
|
||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
]);
|
]);
|
||||||
const installed = type === 'theme'
|
const installedEntry = type === 'theme'
|
||||||
? themeRegistry.themes.some((t) => t.id === slug)
|
? themeRegistry.themes.find((t) => t.id === slug)
|
||||||
: pluginRegistry.plugins.some((p) => p.id === slug);
|
: pluginRegistry.plugins.find((p) => p.id === slug);
|
||||||
|
const installed = installedEntry !== undefined;
|
||||||
|
const installedVersion = installedEntry?.version ?? null;
|
||||||
|
|
||||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||||
const screenshots = Array.isArray(extension.screenshots)
|
const screenshots = Array.isArray(extension.screenshots)
|
||||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||||
altText: s.altText ?? null,
|
altText: s.altText ?? null,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
@@ -170,7 +177,7 @@ export async function GET(
|
|||||||
|
|
||||||
const fileUrl = (path: unknown): string | null =>
|
const fileUrl = (path: unknown): string | null =>
|
||||||
typeof path === 'string' && path
|
typeof path === 'string' && path
|
||||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -206,6 +213,7 @@ export async function GET(
|
|||||||
error: bundleError,
|
error: bundleError,
|
||||||
},
|
},
|
||||||
installed,
|
installed,
|
||||||
|
installedVersion,
|
||||||
},
|
},
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
|
|||||||
import {
|
import {
|
||||||
savePlugin,
|
savePlugin,
|
||||||
saveTheme,
|
saveTheme,
|
||||||
|
getPlugin,
|
||||||
|
getTheme,
|
||||||
getPluginRegistry,
|
getPluginRegistry,
|
||||||
getThemeRegistry,
|
getThemeRegistry,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
@@ -13,13 +15,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
sanitizeHttpOrigins,
|
sanitizeHttpOrigins,
|
||||||
|
sanitizeApiPostPaths,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
async function getDirectoryUrl(): Promise<string> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
@@ -27,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const directoryUrl = await getDirectoryUrl();
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
const url = new URL('/api/v1/extensions', directoryUrl);
|
||||||
|
|
||||||
// Forward all search params
|
// Forward all search params
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
@@ -58,23 +66,32 @@ export async function GET(request: NextRequest) {
|
|||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
const installedPluginVersions = new Map(
|
||||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
|
||||||
|
);
|
||||||
|
const installedThemeVersions = new Map(
|
||||||
|
themeRegistry.themes.map(t => [t.id, t.version] as const),
|
||||||
|
);
|
||||||
|
|
||||||
const fileUrl = (path: unknown): string | null =>
|
const fileUrl = (path: unknown): string | null =>
|
||||||
typeof path === 'string' && path
|
typeof path === 'string' && path
|
||||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
data.data = data.data.map((ext: Record<string, unknown>) => {
|
||||||
...ext,
|
const slug = ext.slug as string;
|
||||||
iconUrl: fileUrl(ext.iconPath),
|
const installedVersion = ext.type === 'theme'
|
||||||
bannerUrl: fileUrl(ext.bannerPath),
|
? installedThemeVersions.get(slug) ?? null
|
||||||
installed: ext.type === 'theme'
|
: installedPluginVersions.get(slug) ?? null;
|
||||||
? installedThemes.has(ext.slug as string)
|
return {
|
||||||
: installedPlugins.has(ext.slug as string),
|
...ext,
|
||||||
}));
|
iconUrl: fileUrl(ext.iconPath),
|
||||||
|
bannerUrl: fileUrl(ext.bannerPath),
|
||||||
|
installed: installedVersion !== null,
|
||||||
|
installedVersion,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(data, {
|
return NextResponse.json(data, {
|
||||||
@@ -92,7 +109,7 @@ export async function GET(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -107,7 +124,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download the bundle from the directory
|
// Download the bundle from the directory
|
||||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
const directoryUrl = await getDirectoryUrl();
|
||||||
|
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
@@ -163,6 +181,18 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Resolve and strictly validate the id used as a filename. Marketplace
|
||||||
|
// bundles are authored by a third-party publisher; without this an id
|
||||||
|
// like "../../foo" causes savePlugin/saveTheme to write outside the
|
||||||
|
// plugins/themes dir via path.join.
|
||||||
|
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
|
||||||
|
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'theme') {
|
if (type === 'theme') {
|
||||||
// Read theme.css
|
// Read theme.css
|
||||||
const cssFile = zip.file(root + 'theme.css');
|
const cssFile = zip.file(root + 'theme.css');
|
||||||
@@ -181,22 +211,43 @@ export async function POST(request: NextRequest) {
|
|||||||
warnings.push(...sanitized.warnings);
|
warnings.push(...sanitized.warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingTheme = await getTheme(resolvedId);
|
||||||
|
const isUpdate = existingTheme !== null;
|
||||||
|
|
||||||
const theme: ServerTheme = {
|
const theme: ServerTheme = {
|
||||||
id: (manifest.id as string) || slug,
|
id: resolvedId,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
version: (manifest.version as string) || version,
|
// Prefer the directory-published version (what we requested) over
|
||||||
|
// manifest.version. Publishers sometimes forget to bump the version
|
||||||
|
// inside the bundle's manifest.json; trusting it would make the
|
||||||
|
// update never appear to "stick" — the registry would keep showing
|
||||||
|
// the older version even after a successful update.
|
||||||
|
version: version || (manifest.version as string),
|
||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||||
enabled: true,
|
enabled: existingTheme?.enabled ?? true,
|
||||||
installedAt: now,
|
...(existingTheme?.forceEnabled !== undefined
|
||||||
|
? { forceEnabled: existingTheme.forceEnabled }
|
||||||
|
: {}),
|
||||||
|
installedAt: existingTheme?.installedAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await saveTheme(theme, css);
|
await saveTheme(theme, css);
|
||||||
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
await auditLog(
|
||||||
|
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
|
||||||
|
{
|
||||||
|
id: theme.id,
|
||||||
|
name: theme.name,
|
||||||
|
version: theme.version,
|
||||||
|
slug,
|
||||||
|
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
|
||||||
|
},
|
||||||
|
ip,
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, theme, warnings });
|
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
|
||||||
} else {
|
} else {
|
||||||
// Plugin installation
|
// Plugin installation
|
||||||
// Read entrypoint JS
|
// Read entrypoint JS
|
||||||
@@ -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 = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: resolvedId,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
version: (manifest.version as string) || version,
|
// See theme branch: trust the directory-published version, not
|
||||||
|
// manifest.version, so updates actually stick in the registry.
|
||||||
|
version: version || (manifest.version as string),
|
||||||
author: (manifest.author as string) || 'Unknown',
|
author: (manifest.author as string) || 'Unknown',
|
||||||
description: (manifest.description as string) || '',
|
description: (manifest.description as string) || '',
|
||||||
type: (manifest.type as string) || 'hook',
|
type: (manifest.type as string) || 'hook',
|
||||||
permissions,
|
permissions,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
enabled: true,
|
enabled: existingPlugin?.enabled ?? true,
|
||||||
installedAt: now,
|
...(existingPlugin?.forceEnabled !== undefined
|
||||||
|
? { forceEnabled: existingPlugin.forceEnabled }
|
||||||
|
: {}),
|
||||||
|
installedAt: existingPlugin?.installedAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
|
: {}),
|
||||||
|
...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
|
||||||
|
? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
|
||||||
|
: {}),
|
||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
...(declaredHttpOrigins.length > 0
|
...(declaredHttpOrigins.length > 0
|
||||||
? { httpOrigins: declaredHttpOrigins }
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredApiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: declaredApiPostPaths }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, 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) {
|
} catch (error) {
|
||||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||||
|
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||||
|
|
||||||
|
async function safeSign(code: string): Promise<string | null> {
|
||||||
|
try { return await signBytes(code); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||||
@@ -25,14 +30,15 @@ export async function GET(
|
|||||||
const devEntry = await getDevPlugin(id);
|
const devEntry = await getDevPlugin(id);
|
||||||
if (devEntry) {
|
if (devEntry) {
|
||||||
const code = await readDevBundle(devEntry);
|
const code = await readDevBundle(devEntry);
|
||||||
return new NextResponse(code, {
|
const signature = await safeSign(code);
|
||||||
headers: {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/javascript; charset=utf-8',
|
'Content-Type': 'application/javascript; charset=utf-8',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||||
},
|
};
|
||||||
});
|
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||||
|
return new NextResponse(code, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = await getPlugin(id);
|
const plugin = await getPlugin(id);
|
||||||
@@ -59,6 +65,9 @@ export async function GET(
|
|||||||
};
|
};
|
||||||
if (etag) headers['ETag'] = etag;
|
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) {
|
if (etag && request.headers.get('if-none-match') === etag) {
|
||||||
return new NextResponse(null, { status: 304, headers });
|
return new NextResponse(null, { status: 304, headers });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminAuth = await requireAdminAuth();
|
const adminAuth = await requireAdminAuth(request);
|
||||||
const isAdmin = !('error' in adminAuth);
|
const isAdmin = !('error' in adminAuth);
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -51,13 +51,18 @@ export async function GET(
|
|||||||
|
|
||||||
const config = await getPluginConfig(id);
|
const config = await getPluginConfig(id);
|
||||||
|
|
||||||
let response: Record<string, unknown> = config;
|
let response: Record<string, unknown>;
|
||||||
if (!isAdmin && plugin.configSchema) {
|
if (isAdmin) {
|
||||||
|
response = config;
|
||||||
|
} else {
|
||||||
response = {};
|
response = {};
|
||||||
for (const [key, value] of Object.entries(config)) {
|
const schema = plugin.configSchema;
|
||||||
const field = plugin.configSchema[key];
|
if (schema) {
|
||||||
if (field?.type === 'secret') continue;
|
for (const [key, value] of Object.entries(config)) {
|
||||||
response[key] = value;
|
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 }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -110,6 +115,13 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plugin.configSchema && !plugin.configSchema[body.key]) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Key is not declared in the plugin configSchema' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await setPluginConfig(id, body.key, body.value);
|
await setPluginConfig(id, body.key, body.value);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -127,7 +139,7 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
|||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
sanitizeHttpOrigins,
|
sanitizeHttpOrigins,
|
||||||
|
sanitizeApiPostPaths,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/plugins - List all admin-managed plugins
|
* GET /api/admin/plugins - List all admin-managed plugins
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const [registry, devEntries] = await Promise.all([
|
const [registry, devEntries] = await Promise.all([
|
||||||
@@ -63,7 +64,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(declaredHttpOrigins.length > 0
|
...(declaredHttpOrigins.length > 0
|
||||||
? { httpOrigins: declaredHttpOrigins }
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredApiPostPaths.length > 0
|
||||||
|
? { apiPostPaths: declaredApiPostPaths }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, 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 });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -217,7 +222,7 @@ export async function POST(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -235,9 +240,18 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
const updated = await updatePluginMeta(id, updates);
|
let updated = await updatePluginMeta(id, updates);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
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.
|
// Enable/disable changes the set of plugins contributing frame origins.
|
||||||
@@ -259,7 +273,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
* Returns current consent + endpoint + next/last send + a live preview
|
* Returns current consent + endpoint + next/last send + a live preview
|
||||||
* of exactly what the next heartbeat would contain.
|
* of exactly what the next heartbeat would contain.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const { consent, source, state } = await effectiveConsent();
|
const { consent, source, state } = await effectiveConsent();
|
||||||
@@ -61,7 +61,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/themes - List all admin-managed themes
|
* GET /api/admin/themes - List all admin-managed themes
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
@@ -36,7 +36,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await requireAdminAuth();
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
* GET /api/admin/version
|
* GET /api/admin/version
|
||||||
* Returns the cached update status, last check times, and effective config.
|
* Returns the cached update status, last check times, and effective config.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(request);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const state = await loadState();
|
const state = await loadState();
|
||||||
@@ -47,7 +47,7 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const auth = await requireAdminAuth();
|
const auth = await requireAdminAuth(req);
|
||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
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: '/' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,10 +20,12 @@ import { recordLogin } from '@/lib/telemetry/login-tracker';
|
|||||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
function sessionCookieOptions() {
|
||||||
...getCookieOptions(),
|
return {
|
||||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
...getCookieOptions(),
|
||||||
};
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getSlot(request: NextRequest): number {
|
function getSlot(request: NextRequest): number {
|
||||||
const raw = request.nextUrl.searchParams.get('slot');
|
const raw = request.nextUrl.searchParams.get('slot');
|
||||||
@@ -88,7 +90,7 @@ export async function POST(request: NextRequest) {
|
|||||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||||
const token = encryptSession(normalizedServerUrl, username, password);
|
const token = encryptSession(normalizedServerUrl, username, password);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
cookieStore.set(cookieName, token, sessionCookieOptions());
|
||||||
setStalwartAuthContextInStore(cookieStore, slot, {
|
setStalwartAuthContextInStore(cookieStore, slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
username,
|
username,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptPayload } from '@/lib/auth/crypto';
|
import { decryptPayload } from '@/lib/auth/crypto';
|
||||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
import {
|
||||||
|
exchangeCodeForTokens,
|
||||||
|
getRequiredConfig,
|
||||||
|
getTokenEndpoint,
|
||||||
|
} from '@/lib/oauth/token-exchange';
|
||||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
@@ -56,6 +60,10 @@ export async function POST(request: NextRequest) {
|
|||||||
const codeVerifier = pending.code_verifier as string;
|
const codeVerifier = pending.code_verifier as string;
|
||||||
const redirectUri = pending.redirect_uri as string;
|
const redirectUri = pending.redirect_uri as string;
|
||||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||||
|
const mobileRedirectUri =
|
||||||
|
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
|
||||||
|
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
|
||||||
|
const isMobileFlow = Boolean(mobileRedirectUri);
|
||||||
|
|
||||||
if (!codeVerifier || !redirectUri) {
|
if (!codeVerifier || !redirectUri) {
|
||||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
@@ -65,21 +73,46 @@ export async function POST(request: NextRequest) {
|
|||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||||
|
|
||||||
// Store refresh token in the per-account cookie slot.
|
// For the mobile handoff flow the tokens are handed back to the app
|
||||||
if (tokens.refresh_token) {
|
// verbatim - we deliberately don't write any cookies on the webmail
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
// origin (the mobile browser tab disposes of the session after the
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
// redirect anyway, but the cookie would still get committed to the
|
||||||
}
|
// user's main webmail session if they happened to be logged in there).
|
||||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
if (!isMobileFlow) {
|
||||||
if (pendingServerId) {
|
if (tokens.refresh_token) {
|
||||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
} else {
|
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||||
cookieStore.delete(serverCookieName);
|
}
|
||||||
|
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||||
|
if (pendingServerId) {
|
||||||
|
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||||
|
} else {
|
||||||
|
cookieStore.delete(serverCookieName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete pending cookie
|
// Delete pending cookie
|
||||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
|
|
||||||
|
if (isMobileFlow) {
|
||||||
|
// The mobile client needs the bits it can't re-derive: the refresh
|
||||||
|
// token, the token endpoint it should hit to refresh later, and the
|
||||||
|
// client_id the IdP expects on that refresh call. The server URL is
|
||||||
|
// returned so the app knows which JMAP host to connect to.
|
||||||
|
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
|
||||||
|
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
|
||||||
|
return NextResponse.json({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
expires_in: tokens.expires_in,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
token_endpoint: tokenEndpoint,
|
||||||
|
client_id: clientId,
|
||||||
|
server_url: serverUrl,
|
||||||
|
mobile_redirect_uri: mobileRedirectUri,
|
||||||
|
mobile_state: mobileState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
|
|||||||
@@ -5,25 +5,48 @@ import { encryptPayload } from '@/lib/auth/crypto';
|
|||||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
|
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||||
|
// this prefix may flow through the mobile handoff path; without the guard
|
||||||
|
// the SSO complete route would be coerced into returning tokens to whatever
|
||||||
|
// caller-controlled URL the attacker chose.
|
||||||
|
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!hasSessionSecret()) {
|
if (!hasSessionSecret()) {
|
||||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { redirect_uri, locale, 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') {
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mobileRedirectUri =
|
||||||
|
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
|
||||||
|
? rawMobileRedirectUri
|
||||||
|
: null;
|
||||||
|
const mobileState =
|
||||||
|
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||||
|
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||||
|
|
||||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||||
@@ -39,7 +62,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl);
|
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||||
|
|
||||||
if (!metadata?.authorization_endpoint) {
|
if (!metadata?.authorization_endpoint) {
|
||||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||||
@@ -52,12 +75,17 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||||
|
// Mobile params are captured here so /complete knows to return tokens to
|
||||||
|
// the caller (in the JSON response) instead of writing the usual server
|
||||||
|
// cookies - and so the callback page can redirect back to the app.
|
||||||
const pendingData = {
|
const pendingData = {
|
||||||
state,
|
state,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
...(serverId ? { server_id: serverId } : {}),
|
...(serverId ? { server_id: serverId } : {}),
|
||||||
|
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||||
|
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encrypted = encryptPayload(pendingData);
|
const encrypted = encryptPayload(pendingData);
|
||||||
@@ -73,7 +101,7 @@ export async function POST(request: NextRequest) {
|
|||||||
authUrl.searchParams.set('response_type', 'code');
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
authUrl.searchParams.set('client_id', clientId);
|
authUrl.searchParams.set('client_id', clientId);
|
||||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||||
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
authUrl.searchParams.set('scope', getOauthScopes());
|
||||||
authUrl.searchParams.set('state', state);
|
authUrl.searchParams.set('state', state);
|
||||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
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 { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
@@ -57,15 +57,22 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slot = getSlot(request, bodySlot);
|
const slot = getSlot(request, bodySlot);
|
||||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
|
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
|
||||||
// just authenticated to JMAP with these credentials, and the cookie we
|
// still bind the cookie's `username` to the credential when we can verify
|
||||||
// write here is only ever consumed for requests on behalf of this same
|
// locally. Without this, a caller can POST username="admin@host" +
|
||||||
// user — a bogus auth header would just yield 401s downstream, not
|
// authHeader=<their own Basic creds>, and downstream consumers that read
|
||||||
// privilege escalation. For untrusted custom endpoints we still verify
|
// the cookie-derived username (audit logs, login tracker) accept the
|
||||||
// upstream as before.
|
// spoof. Bearer tokens are opaque so only the format check runs;
|
||||||
const normalizedServerUrl = upstreamTrusted
|
// authorization sinks must key off the credential itself, not the
|
||||||
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
|
||||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
let normalizedServerUrl: string;
|
||||||
|
if (upstreamTrusted) {
|
||||||
|
validateProxyAuthHeader(authHeader);
|
||||||
|
assertBasicAuthMatchesUsername(authHeader, username);
|
||||||
|
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
|
||||||
|
} else {
|
||||||
|
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||||
|
}
|
||||||
|
|
||||||
await setStalwartAuthContext(slot, {
|
await setStalwartAuthContext(slot, {
|
||||||
serverUrl: normalizedServerUrl,
|
serverUrl: normalizedServerUrl,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async function tryTokenRequest(
|
|||||||
|
|
||||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||||
// 1. Try OAuth discovery
|
// 1. Try OAuth discovery
|
||||||
const metadata = await discoverOAuth(serverUrl);
|
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||||
|
|
||||||
// 2. Try common Stalwart token endpoint paths directly
|
// 2. Try common Stalwart token endpoint paths directly
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -35,6 +36,7 @@ export async function GET() {
|
|||||||
oauthOnly,
|
oauthOnly,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
|
oauthScopes: getOauthScopes(),
|
||||||
rememberMeEnabled: hasSessionSecret(),
|
rememberMeEnabled: hasSessionSecret(),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||||
stalwartFeaturesEnabled,
|
stalwartFeaturesEnabled,
|
||||||
|
|||||||
@@ -106,15 +106,15 @@ const emails: MockEmail[] = [
|
|||||||
// =====================================================================
|
// =====================================================================
|
||||||
{
|
{
|
||||||
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
|
id: 'email-001', threadId: 'thread-001', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4200, receivedAt: daysAgo(0),
|
||||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Willkommen bei Bulwark Webmail!',
|
subject: 'Willkommen bei Bulwark Webmail!',
|
||||||
preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.',
|
preview: 'Hallo! Welcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on JMAP.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }],
|
textBody: [{ partId: 'p1', blobId: 'blob-001', size: 2200, type: 'text/plain' }],
|
||||||
htmlBody: [],
|
htmlBody: [],
|
||||||
bodyValues: {
|
bodyValues: {
|
||||||
p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' },
|
p1: { value: 'Hallo!\n\nWelcome to Bulwark - a modern, open-source webmail client for Stalwart Mail Server, built fresh on the JMAP protocol. No PHP, no 2008 architecture, no plugin-of-plugins archaeology; just clean TypeScript and Next.js, instant push, and a UI that feels like a native app instead of a Gmail polyfill.\n\nWhy JMAP matters: one TLS connection instead of long-polling, push notifications the moment new mail arrives, batched mutations so a click never waits on three round-trips, and threading stitched on the server rather than reassembled in the browser. The result is a webmail that feels quick on a flaky train Wi-Fi and quicker on fibre.\n\nMail, calendar, contacts, and files - everything Stalwart already serves, surfaced through a single window. Threaded inbox with full-text search and Sieve filters. Month, week, day and agenda views with recurring events and iMIP invitations. Multiple address books with vCard import and export. File previews backed by Stalwart\'s JMAP FileNode storage. S/MIME, templates, keyboard shortcuts, dark mode, dozens of languages - the boring stuff that should just work, working.\n\nTwo containers behind your reverse proxy of choice is all it takes to host it yourself: Stalwart for the server side, Bulwark for the client. Caddy, Traefik, nginx - pick one, there are working examples for each. Stalwart stays the source of truth, Bulwark is what you point your browser at, and the setup wizard handles the parts that would otherwise live in a config file.\n\nIt is AGPL, the codebase is small enough to read in an afternoon, and the extension directory already hosts a growing collection of plugins and themes. If something is missing, you can fork it, file an issue, or send a patch - a person will read it.\n\nBeste Grüße,\nSophie' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -197,7 +197,7 @@ const emails: MockEmail[] = [
|
|||||||
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
|
id: 'email-014', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 3400, receivedAt: hoursAgo(2),
|
||||||
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
from: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||||
subject: 'Sprint planning - next week priorities',
|
subject: 'Sprint planning - next week priorities',
|
||||||
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
preview: 'Hej team, here are the priorities for next sprint. Please review before our planning meeting tomorrow.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -367,7 +367,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
|
id: 'email-026', threadId: 'thread-013', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 2400, receivedAt: hoursAgo(1),
|
||||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||||
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
cc: [{ name: 'Dev User', email: 'dev@localhost' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||||
subject: 'Re: Sprint planning - next week priorities',
|
subject: 'Re: Sprint planning - next week priorities',
|
||||||
@@ -471,7 +471,7 @@ const emails: MockEmail[] = [
|
|||||||
{
|
{
|
||||||
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
|
id: 'email-008', threadId: 'thread-007', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 3100, receivedAt: daysAgo(5),
|
||||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
to: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }], cc: [],
|
to: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }], cc: [],
|
||||||
subject: 'Design review feedback',
|
subject: 'Design review feedback',
|
||||||
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
preview: 'Hallo Sophie, I reviewed the new mockups and have a few suggestions.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -485,7 +485,7 @@ const emails: MockEmail[] = [
|
|||||||
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
|
id: 'email-027', threadId: 'thread-013', mailboxIds: { 'mb-sent': true }, keywords: { $seen: true }, size: 1900, receivedAt: hoursAgo(0.5),
|
||||||
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
from: [{ name: 'Dev User', email: 'dev@localhost' }],
|
||||||
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
to: [{ name: 'Lars Johansson', email: 'lars.johansson@fjord-systems.example' }],
|
||||||
cc: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
cc: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }, { name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }],
|
||||||
subject: 'Re: Sprint planning - next week priorities',
|
subject: 'Re: Sprint planning - next week priorities',
|
||||||
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
preview: 'Great suggestions Sophie. 10:30 works for me. I\'ll update the calendar invite.',
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
@@ -639,7 +639,7 @@ const emails: MockEmail[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
|
id: 'email-012', threadId: 'thread-011', mailboxIds: { 'mb-archive': true }, keywords: { $seen: true, $flagged: true }, size: 2600, receivedAt: daysAgo(30),
|
||||||
from: [{ name: 'Sophie Müller', email: 'sophie@eurotech.example' }],
|
from: [{ name: 'Sophie Example', email: 'sophie@eurotech.example' }],
|
||||||
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [],
|
||||||
subject: 'Conference talk accepted!',
|
subject: 'Conference talk accepted!',
|
||||||
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
preview: 'Toll! Your talk proposal for the JMAP Conf has been accepted!',
|
||||||
@@ -728,8 +728,8 @@ const IDENTITIES = [
|
|||||||
email: 'dev@localhost',
|
email: 'dev@localhost',
|
||||||
replyTo: null,
|
replyTo: null,
|
||||||
bcc: null,
|
bcc: null,
|
||||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||||
mayDelete: false,
|
mayDelete: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -743,6 +743,12 @@ const addressBooks = [
|
|||||||
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
|
{ id: 'ab-2', name: 'Arbeit / Work', isDefault: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Profile photos served straight from randomuser.me's CDN; the API at
|
||||||
|
// https://randomuser.me/api/ also returns these portrait URLs, but for a
|
||||||
|
// fixed mock dataset we link them directly to keep things offline-friendly.
|
||||||
|
// See https://randomuser.me/documentation#howto
|
||||||
|
const PORTRAIT = (gender: 'men' | 'women', n: number) => `https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
|
||||||
|
|
||||||
const contacts = [
|
const contacts = [
|
||||||
// --- Personal address book ---
|
// --- Personal address book ---
|
||||||
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-001', uid: 'urn:uuid:c0000001-0000-0000-0000-000000000001', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
@@ -752,6 +758,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'EuroTech GmbH' } },
|
organizations: { o1: { name: 'EuroTech GmbH' } },
|
||||||
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
|
addresses: { a1: { street: [{ value: 'Kurfürstendamm 42' }], locality: 'Berlin', region: '', country: 'Germany', postcode: '10719' } },
|
||||||
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
|
notes: { n1: { note: 'Frontend lead. Always brings Kuchen to the office.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 14), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-002', uid: 'urn:uuid:c0000002-0000-0000-0000-000000000002', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
name: { components: [{ kind: 'given', value: 'Pierre' }, { kind: 'surname', value: 'Dubois' }] },
|
||||||
@@ -760,6 +767,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Dubois Consulting' } },
|
organizations: { o1: { name: 'Dubois Consulting' } },
|
||||||
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
addresses: { a1: { street: [{ value: '42 Rue de Rivoli' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||||
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
|
notes: { n1: { note: 'Product manager. Knows every boulangerie in Paris.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 23), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-003', uid: 'urn:uuid:c0000003-0000-0000-0000-000000000003', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
name: { components: [{ kind: 'given', value: 'Chiara' }, { kind: 'surname', value: 'Rossi' }] },
|
||||||
@@ -768,6 +776,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Rossi Design Studio' } },
|
organizations: { o1: { name: 'Rossi Design Studio' } },
|
||||||
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
addresses: { a1: { street: [{ value: 'Via Montenapoleone 8' }], locality: 'Milano', country: 'Italy', postcode: '20121' } },
|
||||||
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
|
notes: { n1: { note: 'UX designer. Her risotto recipes are legendary.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 40), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-004', uid: 'urn:uuid:c0000004-0000-0000-0000-000000000004', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
name: { components: [{ kind: 'given', value: 'Karel' }, { kind: 'surname', value: 'de Vries' }] },
|
||||||
@@ -775,6 +784,7 @@ const contacts = [
|
|||||||
phones: { p1: { number: '+31 20 555 0142' } },
|
phones: { p1: { number: '+31 20 555 0142' } },
|
||||||
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
|
addresses: { a1: { street: [{ value: 'Herengracht 142' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1015 BN' } },
|
||||||
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
|
notes: { n1: { note: 'Backend developer. Cycles to work rain or shine - true Dutchman.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 45), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-005', uid: 'urn:uuid:c0000005-0000-0000-0000-000000000005', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
name: { components: [{ kind: 'given', value: 'Lars' }, { kind: 'surname', value: 'Johansson' }] },
|
||||||
@@ -783,6 +793,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||||
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
|
addresses: { a1: { street: [{ value: 'Drottninggatan 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '111 51' } },
|
||||||
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
|
notes: { n1: { note: 'Tech lead. FIKA is sacred. Do not schedule meetings during fika.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 61), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-006', uid: 'urn:uuid:c0000006-0000-0000-0000-000000000006', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
name: { components: [{ kind: 'given', value: 'Élise' }, { kind: 'surname', value: 'Moreau' }] },
|
||||||
@@ -791,6 +802,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Fjord Systems AB' } },
|
organizations: { o1: { name: 'Fjord Systems AB' } },
|
||||||
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
|
addresses: { a1: { street: [{ value: '15 Boulevard Saint-Germain' }], locality: 'Paris', country: 'France', postcode: '75005' } },
|
||||||
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
|
notes: { n1: { note: 'Backend dev. Remote from Paris. Once fixed a production bug from a café terrace.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 29), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-007', uid: 'urn:uuid:c0000007-0000-0000-0000-000000000007', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
name: { components: [{ kind: 'given', value: 'Francesco' }, { kind: 'surname', value: 'Bianchi' }] },
|
||||||
@@ -798,6 +810,7 @@ const contacts = [
|
|||||||
phones: { p1: { number: '+39 06 9876 5432' } },
|
phones: { p1: { number: '+39 06 9876 5432' } },
|
||||||
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
|
addresses: { a1: { street: [{ value: 'Via dei Condotti 22' }], locality: 'Roma', country: 'Italy', postcode: '00187' } },
|
||||||
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
|
notes: { n1: { note: 'Old university friend. Once tried to implement RFC 2549 (IP over Avian Carriers) with actual pigeons. It did not scale.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 72), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-008', uid: 'urn:uuid:c0000008-0000-0000-0000-000000000008', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
name: { components: [{ kind: 'given', value: 'Astrid' }, { kind: 'surname', value: 'van der Berg' }] },
|
||||||
@@ -806,6 +819,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'BergLabs' } },
|
organizations: { o1: { name: 'BergLabs' } },
|
||||||
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
|
addresses: { a1: { street: [{ value: 'Prinsengracht 263' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1016 GV' } },
|
||||||
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
|
notes: { n1: { note: 'Solutions architect. Her whiteboard diagrams belong in a museum.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 58), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-009', uid: 'urn:uuid:c0000009-0000-0000-0000-000000000009', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
name: { components: [{ kind: 'given', value: 'Henrik' }, { kind: 'surname', value: 'Nielsen' }] },
|
||||||
@@ -814,6 +828,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Nielsen Konsult' } },
|
organizations: { o1: { name: 'Nielsen Konsult' } },
|
||||||
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
|
addresses: { a1: { street: [{ value: 'Nyhavn 42' }], locality: 'København', country: 'Denmark', postcode: '1051' } },
|
||||||
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
|
notes: { n1: { note: 'Freelance DevOps. Speaks 5 languages. Kubernetes kubectl alias: k → kansen.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 35), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
{ id: 'contact-010', uid: 'urn:uuid:c0000010-0000-0000-0000-000000000010', addressBookIds: { 'ab-1': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
name: { components: [{ kind: 'given', value: 'Isabelle' }, { kind: 'surname', value: 'Martin' }] },
|
||||||
@@ -822,6 +837,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Sorbonne Université' } },
|
organizations: { o1: { name: 'Sorbonne Université' } },
|
||||||
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
|
addresses: { a1: { street: [{ value: '21 Rue de l\'École de Médecine' }], locality: 'Paris', country: 'France', postcode: '75006' } },
|
||||||
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
|
notes: { n1: { note: 'Professor of computer science. Thesis on formal verification of email protocols.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 63), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
// --- Work address book ---
|
// --- Work address book ---
|
||||||
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-011', uid: 'urn:uuid:c0000011-0000-0000-0000-000000000011', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
@@ -831,6 +847,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
organizations: { o1: { name: 'Lefèvre & Associés' } },
|
||||||
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
addresses: { a1: { street: [{ value: '8 Avenue de l\'Opéra' }], locality: 'Paris', country: 'France', postcode: '75001' } },
|
||||||
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
|
notes: { n1: { note: 'Lawyer. Specializes in IP and tech law. Always replies within 42 minutes.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 81), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-012', uid: 'urn:uuid:c0000012-0000-0000-0000-000000000012', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
name: { components: [{ kind: 'given', value: 'Katrin' }, { kind: 'surname', value: 'Bauer' }] },
|
||||||
@@ -839,6 +856,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
organizations: { o1: { name: 'Charité Klinik Berlin' } },
|
||||||
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
|
addresses: { a1: { street: [{ value: 'Charitéplatz 1' }], locality: 'Berlin', country: 'Germany', postcode: '10117' } },
|
||||||
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
|
notes: { n1: { note: 'Medical center admin. Organizes the best team events in Berlin.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 26), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-013', uid: 'urn:uuid:c0000013-0000-0000-0000-000000000013', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
name: { components: [{ kind: 'given', value: 'Liam' }, { kind: 'surname', value: 'Ó Donaill' }] },
|
||||||
@@ -847,6 +865,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Finanz Dublin' } },
|
organizations: { o1: { name: 'Finanz Dublin' } },
|
||||||
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
|
addresses: { a1: { street: [{ value: '42 St. Stephen\'s Green' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 HX65' } },
|
||||||
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
|
notes: { n1: { note: 'Finance lead. Can explain SEPA regulations over a pint of Guinness.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 19), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-014', uid: 'urn:uuid:c0000014-0000-0000-0000-000000000014', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
name: { components: [{ kind: 'given', value: 'María' }, { kind: 'surname', value: 'García' }] },
|
||||||
@@ -855,6 +874,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'García Design Studio' } },
|
organizations: { o1: { name: 'García Design Studio' } },
|
||||||
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
|
addresses: { a1: { street: [{ value: 'Calle Gran Vía 42' }], locality: 'Madrid', country: 'Spain', postcode: '28013' } },
|
||||||
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
|
notes: { n1: { note: 'Brand designer. Her color palettes are pure art. Siesta enthusiast.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 50), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-015', uid: 'urn:uuid:c0000015-0000-0000-0000-000000000015', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
name: { components: [{ kind: 'given', value: 'Nils' }, { kind: 'surname', value: 'Andersson' }] },
|
||||||
@@ -863,6 +883,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Digitaal BV' } },
|
organizations: { o1: { name: 'Digitaal BV' } },
|
||||||
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
addresses: { a1: { street: [{ value: 'Vijzelstraat 42' }], locality: 'Amsterdam', country: 'Netherlands', postcode: '1017 HK' } },
|
||||||
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
|
notes: { n1: { note: 'Platform engineer. fika buddy. Appreciates a good kanelbulle.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 57), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-016', uid: 'urn:uuid:c0000016-0000-0000-0000-000000000016', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Kowalska' }] },
|
||||||
@@ -871,6 +892,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Kowalska Marketing' } },
|
organizations: { o1: { name: 'Kowalska Marketing' } },
|
||||||
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
|
addresses: { a1: { street: [{ value: 'ul. Nowy Świat 42' }], locality: 'Warszawa', country: 'Poland', postcode: '00-363' } },
|
||||||
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
|
notes: { n1: { note: 'Marketing strategist. Her campaign analytics dashboards are works of art.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 71), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-017', uid: 'urn:uuid:c0000017-0000-0000-0000-000000000017', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
name: { components: [{ kind: 'given', value: 'Pádraig' }, { kind: 'surname', value: 'Murphy' }] },
|
||||||
@@ -879,6 +901,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
organizations: { o1: { name: 'Murphy Bau GmbH' } },
|
||||||
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
|
addresses: { a1: { street: [{ value: 'Grafton Street 42' }], locality: 'Dublin', country: 'Ireland', postcode: 'D02 R296' } },
|
||||||
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
|
notes: { n1: { note: 'Construction project manager. Irish-German bilingual. Builds things that last.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 93), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-018', uid: 'urn:uuid:c0000018-0000-0000-0000-000000000018', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
name: { components: [{ kind: 'given', value: 'Raquel' }, { kind: 'surname', value: 'Ferreira' }] },
|
||||||
@@ -887,6 +910,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Ferreira Media' } },
|
organizations: { o1: { name: 'Ferreira Media' } },
|
||||||
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
|
addresses: { a1: { street: [{ value: 'Rua Augusta 42' }], locality: 'Lisboa', country: 'Portugal', postcode: '1100-053' } },
|
||||||
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
|
notes: { n1: { note: 'Media consultant. Can turn any press release into poetry. Loves pastéis de nata.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 82), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-019', uid: 'urn:uuid:c0000019-0000-0000-0000-000000000019', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
name: { components: [{ kind: 'given', value: 'Sébastien' }, { kind: 'surname', value: 'Dumont' }] },
|
||||||
@@ -895,6 +919,7 @@ const contacts = [
|
|||||||
organizations: { o1: { name: 'Dumont Conseil' } },
|
organizations: { o1: { name: 'Dumont Conseil' } },
|
||||||
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
|
addresses: { a1: { street: [{ value: 'Avenue Louise 42' }], locality: 'Bruxelles', country: 'Belgium', postcode: '1050' } },
|
||||||
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
|
notes: { n1: { note: 'Strategy consultant. Knows the difference between Belgian and French chocolate. Will argue passionately about it.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('men', 4), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
{ id: 'contact-020', uid: 'urn:uuid:c0000020-0000-0000-0000-000000000020', addressBookIds: { 'ab-2': true }, kind: 'individual',
|
||||||
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
name: { components: [{ kind: 'given', value: 'Annika' }, { kind: 'surname', value: 'Lindgren' }] },
|
||||||
@@ -904,6 +929,7 @@ const contacts = [
|
|||||||
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
addresses: { a1: { street: [{ value: 'Strandvägen 42' }], locality: 'Stockholm', country: 'Sweden', postcode: '114 56' } },
|
||||||
nicknames: { n1: { name: 'Anni' } },
|
nicknames: { n1: { name: 'Anni' } },
|
||||||
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
|
notes: { n1: { note: 'Independent consultant specializing in GDPR compliance. Yes, she has opinions about cookie banners.' } },
|
||||||
|
media: { photo1: { kind: 'photo' as const, uri: PORTRAIT('women', 36), mediaType: 'image/jpeg' } },
|
||||||
},
|
},
|
||||||
// --- Groups ---
|
// --- Groups ---
|
||||||
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
{ id: 'contact-group-001', addressBookIds: { 'ab-1': true }, kind: 'group' as const,
|
||||||
@@ -976,7 +1002,7 @@ const calendarEvents = [
|
|||||||
participants: {
|
participants: {
|
||||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||||
},
|
},
|
||||||
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
alerts: { a1: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } },
|
||||||
@@ -986,7 +1012,7 @@ const calendarEvents = [
|
|||||||
participants: {
|
participants: {
|
||||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||||
},
|
},
|
||||||
@@ -1024,7 +1050,7 @@ const calendarEvents = [
|
|||||||
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
virtualLocations: { vl1: { uri: 'https://meet.example/eurotech', name: 'Teams' } },
|
||||||
participants: {
|
participants: {
|
||||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||||
},
|
},
|
||||||
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
description: 'Discuss API rate limit escalation for EuroTech enterprise account.',
|
||||||
@@ -1054,7 +1080,7 @@ const calendarEvents = [
|
|||||||
participants: {
|
participants: {
|
||||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
p4: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||||
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
p5: participant('Astrid van der Berg', 'astrid@berglabs.example'),
|
||||||
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
p6: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||||
@@ -1066,7 +1092,7 @@ const calendarEvents = [
|
|||||||
participants: {
|
participants: {
|
||||||
p1: participant('Dev User', 'dev@localhost'),
|
p1: participant('Dev User', 'dev@localhost'),
|
||||||
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
|
p2: participant('María García', 'maria@garcia-design.example', 'owner'),
|
||||||
p3: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p3: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
|
makeEvent('evt-011', 'cal-2', 'API Deprecation Deadline', localDateTime(30, 0, 0), 'P1D', {
|
||||||
@@ -1084,7 +1110,7 @@ const calendarEvents = [
|
|||||||
p2: participant('Dev User', 'dev@localhost'),
|
p2: participant('Dev User', 'dev@localhost'),
|
||||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||||
p5: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p5: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
|
makeEvent('evt-013', 'cal-3', 'Team Retro: What went well?', localDateTime(-2, 16, 0), 'PT1H', {
|
||||||
@@ -1093,7 +1119,7 @@ const calendarEvents = [
|
|||||||
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
p1: participant('Dev User', 'dev@localhost', 'owner'),
|
||||||
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
p2: participant('Lars Johansson', 'lars.johansson@fjord-systems.example'),
|
||||||
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
p3: participant('Élise Moreau', 'elise.moreau@fjord-systems.example'),
|
||||||
p4: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p4: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
|
makeEvent('evt-014', 'cal-3', 'Lunch & Learn: JMAP Protocol Deep Dive', localDateTime(4, 12, 0), 'PT1H', {
|
||||||
@@ -1109,7 +1135,7 @@ const calendarEvents = [
|
|||||||
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
|
location: 'Sophie\'s apartment, Kreuzberg, Berlin',
|
||||||
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
|
description: 'Annual Eurovision Song Contest watch party!\n\nRules:\n1. Scorecards mandatory (printed copies provided)\n2. Drink when someone says "douze points"\n3. Best costume contest (prize: a waffle iron)\n4. No spoilers from the semis!\n\nBring: snacks from your home country.',
|
||||||
participants: {
|
participants: {
|
||||||
p1: participant('Sophie Müller', 'sophie@eurotech.example', 'owner'),
|
p1: participant('Sophie Example', 'sophie@eurotech.example', 'owner'),
|
||||||
p2: participant('Dev User', 'dev@localhost'),
|
p2: participant('Dev User', 'dev@localhost'),
|
||||||
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
p3: participant('Pierre Dubois', 'pierre@dubois.example'),
|
||||||
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
p4: participant('Chiara Rossi', 'chiara@rossi.example'),
|
||||||
@@ -1192,7 +1218,7 @@ const calendarEvents = [
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// ===== Birthday calendar (cal-5) =====
|
// ===== Birthday calendar (cal-5) =====
|
||||||
makeEvent('evt-030', 'cal-5', '🎂 Sophie Müller', localDateTime(8, 0, 0), 'P1D', {
|
makeEvent('evt-030', 'cal-5', '🎂 Sophie Example', localDateTime(8, 0, 0), 'P1D', {
|
||||||
showWithoutTime: true,
|
showWithoutTime: true,
|
||||||
recurrence: [{ frequency: 'yearly' }],
|
recurrence: [{ frequency: 'yearly' }],
|
||||||
description: 'Don\'t forget to bring Kuchen!',
|
description: 'Don\'t forget to bring Kuchen!',
|
||||||
@@ -1220,7 +1246,7 @@ const calendarEvents = [
|
|||||||
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
description: 'Your talk: "Building Modern Webmail with JMAP" - Day 1, 14:00, Main Hall.\nDon\'t forget slide deck!',
|
||||||
participants: {
|
participants: {
|
||||||
p1: participant('Dev User', 'dev@localhost'),
|
p1: participant('Dev User', 'dev@localhost'),
|
||||||
p2: participant('Sophie Müller', 'sophie@eurotech.example'),
|
p2: participant('Sophie Example', 'sophie@eurotech.example'),
|
||||||
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
|
p3: participant('Isabelle Martin', 'isabelle.martin@sorbonne.example'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { NextResponse } from 'next/server';
|
||||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
const policy = configManager.getPolicy();
|
||||||
|
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
|
||||||
|
|
||||||
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||||
getPluginRegistry(),
|
getPluginRegistry(),
|
||||||
getThemeRegistry(),
|
getThemeRegistry(),
|
||||||
@@ -34,7 +39,11 @@ export async function GET() {
|
|||||||
type: p.type,
|
type: p.type,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
entrypoint: p.entrypoint,
|
entrypoint: p.entrypoint,
|
||||||
forceEnabled: p.forceEnabled || false,
|
// Policy is the canonical source for force-enable. The per-plugin field
|
||||||
|
// 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
|
// Content hash + updatedAt let clients detect re-uploads even when
|
||||||
// the manifest version is unchanged.
|
// the manifest version is unchanged.
|
||||||
bundleHash: p.bundleHash,
|
bundleHash: p.bundleHash,
|
||||||
@@ -43,6 +52,8 @@ export async function GET() {
|
|||||||
dev: p.dev,
|
dev: p.dev,
|
||||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||||
httpOrigins: p.httpOrigins,
|
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
|
// Per-user settings schema, captured from the manifest at upload/load
|
||||||
// time so the client can render the settings UI without re-parsing.
|
// time so the client can render the settings UI without re-parsing.
|
||||||
settingsSchema: p.settingsSchema,
|
settingsSchema: p.settingsSchema,
|
||||||
|
|||||||
@@ -1,10 +1,73 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
|
import {
|
||||||
|
getStalwartCredentials,
|
||||||
|
type StalwartCredentials,
|
||||||
|
} from '@/lib/stalwart/credentials';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface ResolvedTarget {
|
||||||
|
authHeader: string;
|
||||||
|
apiUrl: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
|
||||||
|
// that account - not just "the first signed-in slot", which is what
|
||||||
|
// getStalwartCredentials() defaults to. Probe each candidate's session in
|
||||||
|
// parallel and return the first match.
|
||||||
|
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const probes: Promise<ResolvedTarget | null>[] = [];
|
||||||
|
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||||
|
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||||
|
if (!ctx) continue;
|
||||||
|
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
|
||||||
|
probes.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||||
|
headers: { Authorization: ctx.authHeader },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const session = (await res.json()) as {
|
||||||
|
apiUrl?: string;
|
||||||
|
primaryAccounts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||||
|
if (!session.apiUrl || !mailAccountId) return null;
|
||||||
|
if (mailAccountId !== accountId) return null;
|
||||||
|
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const results = await Promise.all(probes);
|
||||||
|
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
|
||||||
|
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
|
||||||
|
headers: { Authorization: creds.authHeader },
|
||||||
|
});
|
||||||
|
if (!sessionRes.ok) return null;
|
||||||
|
const session = (await sessionRes.json()) as {
|
||||||
|
apiUrl?: string;
|
||||||
|
primaryAccounts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const apiUrl = session.apiUrl;
|
||||||
|
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||||
|
if (!apiUrl || !accountId) return null;
|
||||||
|
return { authHeader: creds.authHeader, apiUrl, accountId };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/push/preview
|
* GET /api/push/preview
|
||||||
*
|
*
|
||||||
@@ -19,31 +82,38 @@ export const dynamic = 'force-dynamic';
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const creds = await getStalwartCredentials(request);
|
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
|
||||||
if (!creds) {
|
// StateChange so multi-account browsers fetch from the right slot. Older
|
||||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
// 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`, {
|
const { apiUrl, accountId } = target;
|
||||||
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 inboxRes = await fetch(apiUrl, {
|
const inboxRes = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: creds.authHeader,
|
Authorization: authHeader,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -119,7 +189,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const jmapRes = await fetch(apiUrl, {
|
const jmapRes = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: creds.authHeader,
|
Authorization: authHeader,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
|
||||||
const VALID_SIZES = new Set([192, 512]);
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
// Cache resized images in memory to avoid reprocessing on every request
|
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||||
const cache = new Map<number, Blob>();
|
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||||
|
const cache = new Map<string, Blob>();
|
||||||
|
|
||||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||||
// Absolute URL (http/https)
|
// Absolute URL (http/https)
|
||||||
@@ -16,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
|||||||
return Buffer.from(await res.arrayBuffer());
|
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
|
// Path relative to public/ directory
|
||||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||||
return readFile(publicPath);
|
return readFile(publicPath);
|
||||||
@@ -32,7 +43,11 @@ export async function GET(
|
|||||||
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
await configManager.ensureLoaded();
|
||||||
|
const sources = configManager.getAllWithSources();
|
||||||
|
const iconUrl =
|
||||||
|
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||||
|
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||||
if (!iconUrl) {
|
if (!iconUrl) {
|
||||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -42,9 +57,11 @@ export async function GET(
|
|||||||
'Cache-Control': 'public, max-age=86400',
|
'Cache-Control': 'public, max-age=86400',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cacheKey = `${size}|${iconUrl}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cache.has(size)) {
|
if (cache.has(cacheKey)) {
|
||||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||||
@@ -56,7 +73,7 @@ export async function GET(
|
|||||||
const ab = new ArrayBuffer(resized.byteLength);
|
const ab = new ArrayBuffer(resized.byteLength);
|
||||||
new Uint8Array(ab).set(resized);
|
new Uint8Array(ab).set(resized);
|
||||||
const blob = new Blob([ab], { type: 'image/png' });
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
cache.set(size, blob);
|
cache.set(cacheKey, blob);
|
||||||
|
|
||||||
return new NextResponse(blob, { headers: pngHeaders });
|
return new NextResponse(blob, { headers: pngHeaders });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -47,14 +47,14 @@ function sanitizeFilename(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/setup/branding — wizard branding upload.
|
* POST /api/setup/branding - wizard branding upload.
|
||||||
*
|
*
|
||||||
* Multipart form fields:
|
* Multipart form fields:
|
||||||
* file — the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
* file - the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||||
* slot — which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
* slot - which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||||
*
|
*
|
||||||
* Mirrors /api/admin/branding but authenticates via the wizard cookie
|
* 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
|
* Files land in the same directory; the public read endpoint at
|
||||||
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
||||||
* assets after setup.
|
* assets after setup.
|
||||||
@@ -126,7 +126,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.
|
* config override so the slot falls back to the system default.
|
||||||
*
|
*
|
||||||
* Body: { slot: string }
|
* Body: { slot: string }
|
||||||
|
|||||||
@@ -58,13 +58,16 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Provision the admin account. Aborts cleanly if one already exists
|
// 1. Provision the admin account. An admin.json file may already exist
|
||||||
// (defence in depth - should be impossible in bootstrap state).
|
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
|
||||||
const created = await setInitialAdminPassword(adminPassword);
|
// 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) {
|
if (!created) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Admin account already exists; cannot finish setup again' },
|
{ error: 'Failed to write admin credentials' },
|
||||||
{ status: 409 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
const attrs = buildSessionCookieAttributes();
|
const attrs = buildSessionCookieAttributes(request);
|
||||||
response.cookies.set(attrs.name, submitted, {
|
response.cookies.set(attrs.name, submitted, {
|
||||||
httpOnly: attrs.httpOnly,
|
httpOnly: attrs.httpOnly,
|
||||||
sameSite: attrs.sameSite,
|
sameSite: attrs.sameSite,
|
||||||
|
|||||||
@@ -240,6 +240,13 @@ body {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.email-content-text {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.email-content-text a {
|
.email-content-text a {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
+16
-9
@@ -1,4 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & {
|
|||||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||||
|
|
||||||
export default function manifest(): ExtendedManifest {
|
export default async function manifest(): Promise<ExtendedManifest> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
|
||||||
const appName =
|
const appName =
|
||||||
process.env.APP_NAME ||
|
configManager.get<string>("appName") ||
|
||||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
"Bulwark Webmail";
|
"Bulwark Webmail";
|
||||||
|
|
||||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
const shortName = configManager.get<string>("appShortName") || appName;
|
||||||
const description =
|
const description =
|
||||||
process.env.APP_DESCRIPTION ||
|
configManager.get<string>("appDescription") ||
|
||||||
"A modern webmail client built for Stalwart Mail Server";
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
||||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
||||||
|
|
||||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
||||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
||||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
// 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 =
|
||||||
|
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
|
||||||
|
|
||||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -2,19 +2,49 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
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 {
|
interface CalendarSidebarPanelProps {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
selectedCalendarIds: string[];
|
selectedCalendarIds: string[];
|
||||||
@@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps {
|
|||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
onEditSubscription?: (subscriptionId: string) => void;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
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({
|
export function CalendarSidebarPanel({
|
||||||
@@ -43,12 +79,18 @@ export function CalendarSidebarPanel({
|
|||||||
onSubscribe,
|
onSubscribe,
|
||||||
onEditSubscription,
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
|
multiAccountMode,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
const tMgmt = useTranslations("calendar.management");
|
const tMgmt = useTranslations("calendar.management");
|
||||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
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 refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||||
@@ -65,6 +107,26 @@ export function CalendarSidebarPanel({
|
|||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
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 personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
const sharedAccountGroups = useMemo(() => {
|
const sharedAccountGroups = useMemo(() => {
|
||||||
const shared = calendars.filter(c => c.isShared);
|
const shared = calendars.filter(c => c.isShared);
|
||||||
@@ -79,6 +141,53 @@ export function CalendarSidebarPanel({
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [calendars]);
|
}, [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) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
};
|
};
|
||||||
@@ -263,37 +372,110 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||||
{onCreateCalendar ? (
|
<>
|
||||||
<button
|
{localAccountGroups.map((group, idx) => {
|
||||||
onClick={onCreateCalendar}
|
const expanded = !collapsedAccountGroups.has(group.key);
|
||||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
const isActive = group.key === activeLocalAccountId;
|
||||||
title={tMgmt('add_calendar')}
|
const { owned, sharedGroups } = group.split;
|
||||||
>
|
return (
|
||||||
{t('my_calendars')}
|
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
|
||||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<button
|
||||||
</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"
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
>
|
||||||
{t('my_calendars')}
|
{expanded ? (
|
||||||
</h3>
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
) : (
|
||||||
</div>
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<div className="space-y-0.5">
|
)}
|
||||||
{personalCalendars.map(renderCalendarItem)}
|
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
</div>
|
<span className="text-xs font-semibold text-foreground/90 truncate">
|
||||||
|
{group.label}
|
||||||
{sharedAccountGroups.map((group) => (
|
</span>
|
||||||
<div key={group.accountName} className="mt-4">
|
{isActive && onCreateCalendar && (
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
<span
|
||||||
<Share2 className="w-3 h-3" />
|
role="button"
|
||||||
{group.accountName}
|
tabIndex={0}
|
||||||
</h3>
|
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
|
||||||
<div className="space-y-0.5">
|
onKeyDown={(e) => {
|
||||||
{group.calendars.map(renderCalendarItem)}
|
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>
|
<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()}
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { addDays, startOfWeek } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||||
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
|
|||||||
selectedCalendarIds?: string[];
|
selectedCalendarIds?: string[];
|
||||||
onToggleVisibility?: (id: string) => void;
|
onToggleVisibility?: (id: string) => void;
|
||||||
enableCalendarTasks?: boolean;
|
enableCalendarTasks?: boolean;
|
||||||
|
/** Show a burger button at the start that opens the (overlay) sidebar. */
|
||||||
|
onMenuClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarToolbar({
|
export function CalendarToolbar({
|
||||||
@@ -45,6 +47,7 @@ export function CalendarToolbar({
|
|||||||
selectedCalendarIds,
|
selectedCalendarIds,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
enableCalendarTasks,
|
enableCalendarTasks,
|
||||||
|
onMenuClick,
|
||||||
}: CalendarToolbarProps) {
|
}: CalendarToolbarProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const formatter = useFormatter();
|
const formatter = useFormatter();
|
||||||
@@ -115,11 +118,32 @@ export function CalendarToolbar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
|
<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 ── */}
|
{/* ── MOBILE TOOLBAR ── */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="flex flex-col gap-1 px-2 py-2">
|
<div className="flex flex-col gap-1 px-2 py-2">
|
||||||
{/* Row 1: Back / Date nav / Today */}
|
{/* Row 1: Back / Date nav / Today */}
|
||||||
<div className="flex items-center gap-1">
|
<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 && (
|
{onNavigateBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateBack}
|
onClick={onNavigateBack}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react";
|
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||||
@@ -76,7 +76,65 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||||
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
|
||||||
|
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
||||||
|
|
||||||
|
interface AlertRow {
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
unit: AlertUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alertRowSeq = 0;
|
||||||
|
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
|
||||||
|
alertRowSeq += 1;
|
||||||
|
return { id: `r${alertRowSeq}`, value, unit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertRowToOffset(row: AlertRow): string | null {
|
||||||
|
if (row.unit === "at_time") return "PT0S";
|
||||||
|
const v = Math.max(0, Math.floor(row.value));
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return null;
|
||||||
|
switch (row.unit) {
|
||||||
|
case "minutes": return `-PT${v}M`;
|
||||||
|
case "hours": return `-PT${v}H`;
|
||||||
|
case "days": return `-P${v}D`;
|
||||||
|
case "weeks": return `-P${v}W`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetToAlertRow(offset: string): AlertRow | null {
|
||||||
|
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
|
||||||
|
return newAlertRow(0, "at_time");
|
||||||
|
}
|
||||||
|
let m = offset.match(/^-?P(\d+)W$/);
|
||||||
|
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
|
||||||
|
m = offset.match(/^-?P(\d+)D$/);
|
||||||
|
if (m) return newAlertRow(parseInt(m[1], 10), "days");
|
||||||
|
m = offset.match(/^-?PT(\d+)H$/);
|
||||||
|
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
|
||||||
|
m = offset.match(/^-?PT(\d+)M$/);
|
||||||
|
if (m) {
|
||||||
|
const mins = parseInt(m[1], 10);
|
||||||
|
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
|
||||||
|
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
|
||||||
|
return newAlertRow(mins, "minutes");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAlertRowLabel(
|
||||||
|
row: { value: number; unit: AlertUnit },
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
|
): string {
|
||||||
|
if (row.unit === "at_time") return t("alerts.at_time");
|
||||||
|
switch (row.unit) {
|
||||||
|
case "minutes": return t("alerts.minutes_before", { count: row.value });
|
||||||
|
case "hours": return t("alerts.hours_before", { count: row.value });
|
||||||
|
case "days": return t("alerts.days_before", { count: row.value });
|
||||||
|
case "weeks": return t("alerts.weeks_before", { count: row.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDurationDisplay(minutes: number): string {
|
function formatDurationDisplay(minutes: number): string {
|
||||||
if (minutes < 60) return `${minutes}min`;
|
if (minutes < 60) return `${minutes}min`;
|
||||||
@@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string {
|
|||||||
|
|
||||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
if (!event.alerts) return null;
|
if (!event.alerts) return null;
|
||||||
const first = Object.values(event.alerts)[0];
|
const labels: string[] = [];
|
||||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
for (const alert of Object.values(event.alerts)) {
|
||||||
const offset = first.trigger.offset;
|
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
|
||||||
if (offset === "PT0S") return t("alerts.at_time");
|
const row = offsetToAlertRow(alert.trigger.offset);
|
||||||
const minMatch = offset.match(/-?PT(\d+)M$/);
|
if (!row) continue;
|
||||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
labels.push(formatAlertRowLabel(row, t));
|
||||||
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
}
|
||||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
if (labels.length === 0) return null;
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
return labels.join(", ");
|
||||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
@@ -216,22 +272,36 @@ export function EventModal({
|
|||||||
if (!event?.recurrenceRules?.length) return "none";
|
if (!event?.recurrenceRules?.length) return "none";
|
||||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
||||||
});
|
});
|
||||||
const [alert, setAlert] = useState<AlertOption>(() => {
|
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||||
if (!event?.alerts) return "none";
|
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||||
const first = Object.values(event.alerts)[0];
|
if (!event?.alerts) return [];
|
||||||
if (!first) return "none";
|
const rows: AlertRow[] = [];
|
||||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
for (const [id, alert] of Object.entries(event.alerts)) {
|
||||||
const offset = first.trigger.offset;
|
// Preserve alerts we can't represent in this UI (absolute triggers,
|
||||||
if (offset === "PT0S") return "at_time";
|
// email actions, offsets with non-canonical shapes) so they survive a save.
|
||||||
const minMatch = offset.match(/-?PT(\d+)M$/);
|
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
|
||||||
if (minMatch) return minMatch[1] as AlertOption;
|
preservedAlertsRef.current[id] = alert;
|
||||||
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
continue;
|
||||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
}
|
||||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
const row = offsetToAlertRow(alert.trigger.offset);
|
||||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
if (!row) {
|
||||||
|
preservedAlertsRef.current[id] = alert;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
}
|
}
|
||||||
return "none";
|
return rows;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const addAlertRow = useCallback(() => {
|
||||||
|
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
|
||||||
|
}, []);
|
||||||
|
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
|
||||||
|
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
}, []);
|
||||||
|
const removeAlertRow = useCallback((id: string) => {
|
||||||
|
setAlertRows((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}, []);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
@@ -400,17 +470,23 @@ export function EventModal({
|
|||||||
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
|
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (alert !== "none") {
|
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
|
||||||
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`;
|
let alertIdx = 0;
|
||||||
data.alerts = {
|
for (const row of alertRows) {
|
||||||
alert1: {
|
const offset = alertRowToOffset(row);
|
||||||
"@type": "Alert",
|
if (offset === null) continue;
|
||||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
let key = `alert${++alertIdx}`;
|
||||||
action: "display",
|
while (key in builtAlerts) key = `alert${++alertIdx}`;
|
||||||
acknowledged: null,
|
builtAlerts[key] = {
|
||||||
relatedTo: null,
|
"@type": "Alert",
|
||||||
},
|
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||||
|
action: "display",
|
||||||
|
acknowledged: null,
|
||||||
|
relatedTo: null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
if (Object.keys(builtAlerts).length > 0) {
|
||||||
|
data.alerts = builtAlerts;
|
||||||
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
|
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
|
||||||
data.alerts = null;
|
data.alerts = null;
|
||||||
}
|
}
|
||||||
@@ -435,7 +511,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -987,37 +1063,81 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div>
|
||||||
<div>
|
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
<select
|
||||||
<select
|
value={recurrence}
|
||||||
value={recurrence}
|
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
||||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
>
|
||||||
>
|
<option value="none">{t("recurrence.none")}</option>
|
||||||
<option value="none">{t("recurrence.none")}</option>
|
<option value="daily">{t("recurrence.daily")}</option>
|
||||||
<option value="daily">{t("recurrence.daily")}</option>
|
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||||
<select
|
{alertRows.length === 0 ? (
|
||||||
value={alert}
|
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
|
||||||
onChange={(e) => setAlert(e.target.value as AlertOption)}
|
) : (
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
<div className="space-y-2">
|
||||||
>
|
{alertRows.map((row) => (
|
||||||
<option value="none">{t("alerts.none")}</option>
|
<div key={row.id} className="flex items-center gap-2">
|
||||||
<option value="at_time">{t("alerts.at_time")}</option>
|
{row.unit !== "at_time" && (
|
||||||
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option>
|
<Input
|
||||||
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option>
|
type="number"
|
||||||
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option>
|
min={1}
|
||||||
<option value="60">{t("alerts.hours_before", { count: 1 })}</option>
|
max={999}
|
||||||
<option value="1440">{t("alerts.days_before", { count: 1 })}</option>
|
value={row.value}
|
||||||
</select>
|
onChange={(e) => {
|
||||||
</div>
|
const n = parseInt(e.target.value, 10);
|
||||||
|
updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
|
||||||
|
}}
|
||||||
|
className="w-20"
|
||||||
|
aria-label={t("alerts.amount")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const unit = e.target.value as AlertUnit;
|
||||||
|
updateAlertRow(row.id, {
|
||||||
|
unit,
|
||||||
|
value: unit === "at_time" ? 0 : (row.value || 1),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
aria-label={t("alerts.unit")}
|
||||||
|
>
|
||||||
|
<option value="at_time">{t("alerts.at_time")}</option>
|
||||||
|
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
|
||||||
|
<option value="hours">{t("alerts.unit_hours_before")}</option>
|
||||||
|
<option value="days">{t("alerts.unit_days_before")}</option>
|
||||||
|
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAlertRow(row.id)}
|
||||||
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label={t("alerts.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addAlertRow}
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t("alerts.add")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{attendees.length > 0 && (
|
{attendees.length > 0 && (
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
|
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { normalizeContactPhotoUri } from "@/stores/contact-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ interface ContactFormProps {
|
|||||||
addressBooks?: AddressBook[];
|
addressBooks?: AddressBook[];
|
||||||
allKeywords?: string[];
|
allKeywords?: string[];
|
||||||
defaultAddressBookId?: string;
|
defaultAddressBookId?: string;
|
||||||
|
/** Prefills the create form (ignored when `contact` is set). */
|
||||||
|
prefill?: { email?: string; name?: string };
|
||||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -144,10 +147,22 @@ function Select({ value, onChange, children, className }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) {
|
||||||
const t = useTranslations("contacts.form");
|
const t = useTranslations("contacts.form");
|
||||||
const isEditing = !!contact;
|
const isEditing = !!contact;
|
||||||
|
|
||||||
|
// Split a free-form display name into given/surname for prefill.
|
||||||
|
const prefillGivenName = (() => {
|
||||||
|
if (contact || !prefill?.name) return "";
|
||||||
|
const parts = prefill.name.trim().split(/\s+/);
|
||||||
|
return parts[0] || "";
|
||||||
|
})();
|
||||||
|
const prefillSurname = (() => {
|
||||||
|
if (contact || !prefill?.name) return "";
|
||||||
|
const parts = prefill.name.trim().split(/\s+/);
|
||||||
|
return parts.slice(1).join(" ");
|
||||||
|
})();
|
||||||
|
|
||||||
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||||
const findComponent = (...kinds: string[]) =>
|
const findComponent = (...kinds: string[]) =>
|
||||||
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
||||||
@@ -214,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
||||||
const [surname, setSurname] = useState(findComponent("surname"));
|
const [surname, setSurname] = useState(findComponent("surname") || prefillSurname);
|
||||||
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(
|
const [nickname, setNickname] = useState(
|
||||||
@@ -230,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
|
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return [{ address: "", context: "" }];
|
return [{ address: prefill?.email || "", context: "" }];
|
||||||
});
|
});
|
||||||
|
|
||||||
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
|
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
|
||||||
@@ -341,7 +356,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
|||||||
const initialPhotoEntry = useMemo(() => {
|
const initialPhotoEntry = useMemo(() => {
|
||||||
if (!contact?.media) return null;
|
if (!contact?.media) return null;
|
||||||
for (const [key, m] of Object.entries(contact.media)) {
|
for (const [key, m] of Object.entries(contact.media)) {
|
||||||
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
|
if (m.kind === "photo" && m.uri) {
|
||||||
|
return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [contact]);
|
}, [contact]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
|
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContactListItem } from "./contact-list-item";
|
import { ContactListItem } from "./contact-list-item";
|
||||||
@@ -112,6 +112,8 @@ interface ContactListProps {
|
|||||||
onEditContact: (id: string) => void;
|
onEditContact: (id: string) => void;
|
||||||
onDeleteContact: (contact: ContactCard) => void;
|
onDeleteContact: (contact: ContactCard) => void;
|
||||||
onAddContactToGroup: (id: string) => void;
|
onAddContactToGroup: (id: string) => void;
|
||||||
|
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
|
||||||
|
onMenuClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactList({
|
export function ContactList({
|
||||||
@@ -133,6 +135,7 @@ export function ContactList({
|
|||||||
onEditContact,
|
onEditContact,
|
||||||
onDeleteContact,
|
onDeleteContact,
|
||||||
onAddContactToGroup,
|
onAddContactToGroup,
|
||||||
|
onMenuClick,
|
||||||
}: ContactListProps) {
|
}: ContactListProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@@ -267,6 +270,16 @@ export function ContactList({
|
|||||||
<div className="border-b border-border bg-background">
|
<div className="border-b border-border bg-background">
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{onMenuClick && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label={t("open_categories")}
|
||||||
|
>
|
||||||
|
<Menu className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||||
@@ -10,6 +10,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
|
||||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||||
|
|
||||||
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
|
|||||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||||
onRenameKeyword?: (keyword: string) => void;
|
onRenameKeyword?: (keyword: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Pro shell: render one collapsible section per connected local account
|
||||||
|
* (active first), each with "My Address Books" / "Shared from X"
|
||||||
|
* subsections. Mirrors the calendar sidebar's Pro layout.
|
||||||
|
*/
|
||||||
|
multiAccountMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddressBookAccountSplit = {
|
||||||
|
owned: AddressBook[];
|
||||||
|
sharedGroups: { label: string; books: AddressBook[] }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitAccountBooks(list: AddressBook[]): AddressBookAccountSplit {
|
||||||
|
const owned: AddressBook[] = [];
|
||||||
|
const sharedBuckets = new Map<string, { label: string; books: AddressBook[] }>();
|
||||||
|
for (const book of list) {
|
||||||
|
if (book.isShared) {
|
||||||
|
const key = book.accountId || book.accountName || book.id;
|
||||||
|
const bucket = sharedBuckets.get(key);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.books.push(book);
|
||||||
|
} else {
|
||||||
|
sharedBuckets.set(key, { label: book.accountName || key, books: [book] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
owned.push(book);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||||
@@ -70,6 +101,7 @@ export function ContactsSidebar({
|
|||||||
onDeleteAddressBook,
|
onDeleteAddressBook,
|
||||||
onRenameKeyword,
|
onRenameKeyword,
|
||||||
className,
|
className,
|
||||||
|
multiAccountMode,
|
||||||
}: ContactsSidebarProps) {
|
}: ContactsSidebarProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -136,6 +168,47 @@ export function ContactsSidebar({
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}, [addressBooks]);
|
}, [addressBooks]);
|
||||||
|
|
||||||
|
// Pro / multi-account grouping: each local account is its own collapsible
|
||||||
|
// section with owned / shared sub-buckets.
|
||||||
|
const localAccounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
const localAccountGroups = useMemo(() => {
|
||||||
|
if (!multiAccountMode) return [];
|
||||||
|
const byAccount = new Map<string, AddressBook[]>();
|
||||||
|
for (const book of addressBooks) {
|
||||||
|
const key = book.localAccountId || '__other__';
|
||||||
|
const list = byAccount.get(key) ?? [];
|
||||||
|
list.push(book);
|
||||||
|
byAccount.set(key, list);
|
||||||
|
}
|
||||||
|
const ordered: { key: string; label: string; split: AddressBookAccountSplit }[] = [];
|
||||||
|
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: splitAccountBooks(byAccount.get(activeLocalAccountId)!),
|
||||||
|
});
|
||||||
|
byAccount.delete(activeLocalAccountId);
|
||||||
|
}
|
||||||
|
for (const acct of localAccounts) {
|
||||||
|
if (!byAccount.has(acct.id)) continue;
|
||||||
|
ordered.push({
|
||||||
|
key: acct.id,
|
||||||
|
label: acct.label || acct.email || acct.username,
|
||||||
|
split: splitAccountBooks(byAccount.get(acct.id)!),
|
||||||
|
});
|
||||||
|
byAccount.delete(acct.id);
|
||||||
|
}
|
||||||
|
for (const [key, list] of byAccount.entries()) {
|
||||||
|
const fallback = key === '__other__'
|
||||||
|
? t('address_books.title')
|
||||||
|
: list[0]?.accountName || key;
|
||||||
|
ordered.push({ key, label: fallback, split: splitAccountBooks(list) });
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [multiAccountMode, addressBooks, localAccounts, activeLocalAccountId, t]);
|
||||||
|
|
||||||
// Count contacts per address book
|
// Count contacts per address book
|
||||||
const contactCountByBook = useMemo(() => {
|
const contactCountByBook = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
@@ -257,47 +330,117 @@ export function ContactsSidebar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* My Address Books */}
|
{/* Address Books: per-account groups in multi-account Pro mode, else the
|
||||||
{personalBooks.length > 0 && (
|
classic "My Address Books" section. */}
|
||||||
<div className="mt-2">
|
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||||
<div className="flex items-center px-3 py-1 group">
|
localAccountGroups.map((group) => {
|
||||||
<button
|
const sectionKey = `account-${group.key}`;
|
||||||
onClick={() => toggleSection("addressBooks")}
|
const expanded = !collapsed[sectionKey];
|
||||||
className="flex items-center gap-1 flex-1 text-left"
|
const { owned, sharedGroups } = group.split;
|
||||||
>
|
return (
|
||||||
{collapsed.addressBooks ? (
|
<div key={group.key} className="mt-2">
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
<div className="flex items-center px-3 py-1 group">
|
||||||
) : (
|
<button
|
||||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
onClick={() => toggleSection(sectionKey)}
|
||||||
|
className="flex items-center gap-1 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<User className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-semibold text-foreground/90 uppercase tracking-wider truncate">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="pl-2">
|
||||||
|
{owned.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||||
|
{t("address_books.title")}
|
||||||
|
</div>
|
||||||
|
{owned.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sharedGroups.map((sg) => (
|
||||||
|
<div key={`${group.key}-shared-${sg.label}`} className="mt-1">
|
||||||
|
<div className="px-3 py-0.5 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>
|
||||||
|
{sg.books.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
</div>
|
||||||
{t("address_books.title")}
|
);
|
||||||
</span>
|
})
|
||||||
</button>
|
) : (
|
||||||
<button
|
personalBooks.length > 0 && (
|
||||||
onClick={(e) => {
|
<div className="mt-2">
|
||||||
e.stopPropagation();
|
<div className="flex items-center px-3 py-1 group">
|
||||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
<button
|
||||||
router.push('/settings');
|
onClick={() => toggleSection("addressBooks")}
|
||||||
}}
|
className="flex items-center gap-1 flex-1 text-left"
|
||||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
>
|
||||||
title={t("address_books.manage")}
|
{collapsed.addressBooks ? (
|
||||||
>
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
) : (
|
||||||
</button>
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("address_books.title")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||||
|
title={t("address_books.manage")}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||||
|
<AddressBookItem
|
||||||
|
key={book.id}
|
||||||
|
book={book}
|
||||||
|
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
||||||
|
contactCount={contactCountByBook[book.id] || 0}
|
||||||
|
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||||
|
onDropContacts={onDropContacts}
|
||||||
|
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
)
|
||||||
<AddressBookItem
|
|
||||||
key={book.id}
|
|
||||||
book={book}
|
|
||||||
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
|
|
||||||
contactCount={contactCountByBook[book.id] || 0}
|
|
||||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
|
||||||
onDropContacts={onDropContacts}
|
|
||||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Groups section */}
|
{/* Groups section */}
|
||||||
@@ -398,8 +541,9 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shared accounts with address books */}
|
{/* Shared accounts with address books - only when not already split
|
||||||
{sharedBookGroups.map((group) => (
|
into per-account groups above (multi-account Pro mode). */}
|
||||||
|
{!multiAccountMode && sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
<div className="flex items-center px-3 py-1 group">
|
<div className="flex items-center px-3 py-1 group">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
setActionNotice(null);
|
setActionNotice(null);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
|
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||||
if (events.length > 0) {
|
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in
|
||||||
const parsed = events[0];
|
// parallel with parsing to save a roundtrip.
|
||||||
setParsedEvent(parsed);
|
const [events, rawText] = await Promise.all([
|
||||||
|
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
||||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
(async () => {
|
||||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
|
try {
|
||||||
try {
|
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
return await blob.text();
|
||||||
const rawText = await blob.text();
|
} catch {
|
||||||
const icsMethod = extractMethodFromRawIcs(rawText);
|
return null;
|
||||||
if (icsMethod !== 'unknown') {
|
|
||||||
setRawIcsMethod(icsMethod);
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore - fall back to heuristic detection */ }
|
})(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (parsed.uid && supportsCalendar) {
|
if (events.length === 0) {
|
||||||
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
|
||||||
if (!storeHasIt) {
|
|
||||||
try {
|
|
||||||
const serverEvents = await client.queryCalendarEvents({});
|
|
||||||
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
|
|
||||||
if (matching.length > 0) {
|
|
||||||
useCalendarStore.setState((s) => {
|
|
||||||
const existingIds = new Set(s.events.map((e) => e.id));
|
|
||||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
|
||||||
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* ignore lookup failure */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setState('parsed');
|
|
||||||
} else {
|
|
||||||
setState('error');
|
setState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = events[0];
|
||||||
|
setParsedEvent(parsed);
|
||||||
|
|
||||||
|
if (rawText) {
|
||||||
|
const icsMethod = extractMethodFromRawIcs(rawText);
|
||||||
|
if (icsMethod !== 'unknown') {
|
||||||
|
setRawIcsMethod(icsMethod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('parsed');
|
||||||
|
|
||||||
|
// Hydrate the calendar store with the matching event in the background -
|
||||||
|
// only needed for the "already in calendar" pill, must not block the banner.
|
||||||
|
// Filter by UID server-side; the previous unfiltered query fetched up to
|
||||||
|
// 1000 events plus multiple /get batches just to find one match.
|
||||||
|
if (parsed.uid && supportsCalendar) {
|
||||||
|
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||||
|
if (!storeHasIt) {
|
||||||
|
client.queryCalendarEvents({ uid: parsed.uid })
|
||||||
|
.then((matching) => {
|
||||||
|
if (matching.length === 0) return;
|
||||||
|
useCalendarStore.setState((s) => {
|
||||||
|
const existingIds = new Set(s.events.map((e) => e.id));
|
||||||
|
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||||
|
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => { /* ignore lookup failure */ });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setState('error');
|
setState('error');
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
|
|||||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useIdentityStore } from "@/stores/identity-store";
|
import { useIdentityStore } from "@/stores/identity-store";
|
||||||
|
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -34,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
|
|||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
|
import {
|
||||||
|
rewriteCidImagesForEditor,
|
||||||
|
replaceInlineImagePlaceholders,
|
||||||
|
} from "@/lib/email-composer-utils";
|
||||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
|
|
||||||
@@ -56,7 +61,7 @@ export interface ComposerDraftData {
|
|||||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
replyTo?: EmailComposerProps['replyTo'];
|
replyTo?: EmailComposerProps['replyTo'];
|
||||||
draftId: string | null;
|
draftId: string | null;
|
||||||
/** When set, overrides the header From: — sent through the selected identity's envelope. */
|
/** When set, overrides the header From: - sent through the selected identity's envelope. */
|
||||||
fromOverrideEmail?: string;
|
fromOverrideEmail?: string;
|
||||||
fromOverrideName?: string;
|
fromOverrideName?: string;
|
||||||
fromOverrideEnabled?: boolean;
|
fromOverrideEnabled?: boolean;
|
||||||
@@ -75,6 +80,11 @@ interface EmailComposerProps {
|
|||||||
fromName?: string;
|
fromName?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
envelopeMailFrom?: string;
|
envelopeMailFrom?: string;
|
||||||
|
/** Local account ID owning the selected identity. Set when the user
|
||||||
|
* picked an identity from a non-active account in the Pro multi-
|
||||||
|
* account dropdown; parents should send through that account's
|
||||||
|
* client instead of the currently-active one. */
|
||||||
|
localAccountId?: string;
|
||||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
@@ -103,6 +113,14 @@ interface EmailComposerProps {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
|
// Pre-built quote header block. Supplied by the composer opener after it
|
||||||
|
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
|
||||||
|
// the composer uses these verbatim instead of building its own default
|
||||||
|
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
|
||||||
|
quoteHeaderHtml?: string;
|
||||||
|
quoteHeaderText?: string;
|
||||||
|
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
|
||||||
|
quoteWrapInBlockquote?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +155,7 @@ function buildEmbeddedSignatureHtml(
|
|||||||
: `<p data-signature-block="start"></p>`;
|
: `<p data-signature-block="start"></p>`;
|
||||||
const endMarker = `<p data-signature-block="end"></p>`;
|
const endMarker = `<p data-signature-block="end"></p>`;
|
||||||
if (identity?.htmlSignature) {
|
if (identity?.htmlSignature) {
|
||||||
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
|
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
|
||||||
}
|
}
|
||||||
if (identity?.textSignature) {
|
if (identity?.textSignature) {
|
||||||
const escaped = identity.textSignature
|
const escaped = identity.textSignature
|
||||||
@@ -171,8 +189,18 @@ export function EmailComposer({
|
|||||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||||
const identities = useIdentityStore((s) => s.identities);
|
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||||
const primaryIdentity = identities[0] ?? null;
|
// Pro shell: surface identities from every connected account, grouped
|
||||||
|
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
||||||
|
// the active account's identities only.
|
||||||
|
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||||
|
const identities = multiAccountIdentities.enabled
|
||||||
|
? multiAccountIdentities.allIdentities
|
||||||
|
: activeIdentities;
|
||||||
|
const identityGroups = multiAccountIdentities.enabled
|
||||||
|
? multiAccountIdentities.groups
|
||||||
|
: [];
|
||||||
|
const primaryIdentity = activeIdentities[0] ?? null;
|
||||||
|
|
||||||
// The signature identity used when embedding the signature into the initial
|
// The signature identity used when embedding the signature into the initial
|
||||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||||
@@ -239,13 +267,19 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// When "above quote" is configured, splice signature between the user's
|
// When "above quote" is configured, splice signature between the user's
|
||||||
// drafting area and the quoted content so it reads naturally as a
|
// drafting area and the quoted content so it reads naturally as a
|
||||||
// closing for the reply body. Send-time append is skipped — see
|
// closing for the reply body. Send-time append is skipped - see
|
||||||
// shouldEmbedSignatureAboveQuote.
|
// shouldEmbedSignatureAboveQuote.
|
||||||
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||||
const signatureBlock = shouldEmbedSignatureAboveQuote
|
const signatureBlock = shouldEmbedSignatureAboveQuote
|
||||||
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||||
|
if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
|
const body = mode === 'forward' ? originalText : quotedText;
|
||||||
|
return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'forward') {
|
if (mode === 'forward') {
|
||||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
@@ -266,12 +300,29 @@ export function EmailComposer({
|
|||||||
separator: signatureSeparatorEnabled,
|
separator: signatureSeparatorEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||||
|
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
|
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||||
|
const originalHtml = replyTo.htmlBody
|
||||||
|
? rewriteCidImagesForEditor(replyTo.htmlBody)
|
||||||
|
: (replyTo.body
|
||||||
|
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||||
|
: '');
|
||||||
|
const bodyHtml = wrap
|
||||||
|
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
||||||
|
: originalHtml;
|
||||||
|
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Build quoted content as HTML
|
// Build quoted content as HTML
|
||||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
const quoteHeader = mode === 'forward'
|
const quoteHeader = mode === 'forward'
|
||||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
// cid: image refs are rewritten so they render in the editor (browsers
|
||||||
|
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
|
||||||
|
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
|
||||||
|
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyTo.body) {
|
if (replyTo.body) {
|
||||||
@@ -293,9 +344,17 @@ export function EmailComposer({
|
|||||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||||
|
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||||
|
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||||
|
// value and try to destroy a draft that was just replaced.
|
||||||
|
const draftIdRef = useRef<string | null>(initialData?.draftId ?? null);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>("");
|
const lastSavedDataRef = useRef<string>("");
|
||||||
|
// Tracks the currently-running saveDraft so concurrent callers (autosave
|
||||||
|
// timer + send button) serialize instead of issuing parallel destroy/create
|
||||||
|
// requests with the same draftId. See bug #303.
|
||||||
|
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||||
return replyTo.attachments
|
return replyTo.attachments
|
||||||
@@ -354,6 +413,19 @@ export function EmailComposer({
|
|||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: primaryIdentity;
|
: primaryIdentity;
|
||||||
|
|
||||||
|
// When the selected identity belongs to a non-active account (Pro
|
||||||
|
// multi-account dropdown), `currentIdentity.id` carries a "<localId>::"
|
||||||
|
// namespace and JMAP calls must be routed through that account's
|
||||||
|
// client with the un-prefixed id. `composerClient` and
|
||||||
|
// `currentIdentityRawId` are what save/send code should use.
|
||||||
|
const currentIdentityParts = currentIdentity?.id
|
||||||
|
? stripCrossAccountIdentityPrefix(currentIdentity.id)
|
||||||
|
: { localAccountId: null, rawId: undefined };
|
||||||
|
const composerClient = currentIdentityParts.localAccountId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client)
|
||||||
|
: client;
|
||||||
|
const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id;
|
||||||
// Alias identities often lack a configured signature - fall back to the primary
|
// Alias identities often lack a configured signature - fall back to the primary
|
||||||
// identity's signature so replies (which auto-select a matching alias) still
|
// identity's signature so replies (which auto-select a matching alias) still
|
||||||
// populate the user's signature.
|
// populate the user's signature.
|
||||||
@@ -470,8 +542,78 @@ export function EmailComposer({
|
|||||||
selectedIdentityId,
|
selectedIdentityId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Hydrate inline images referenced by the quoted body (issue #163).
|
||||||
|
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
|
||||||
|
// data-cid; here we (1) register each inline attachment in inlineImagesRef
|
||||||
|
// so the send path re-attaches the blob with the right cid, and (2) fetch
|
||||||
|
// each blob as a data URL and swap it into the body so the editor actually
|
||||||
|
// shows the image instead of a blank placeholder.
|
||||||
|
useEffect(() => {
|
||||||
|
if (plainTextMode) return;
|
||||||
|
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||||
|
if (!composerClient || !replyTo?.attachments?.length) return;
|
||||||
|
|
||||||
|
const inlineAtts = replyTo.attachments.filter((att) =>
|
||||||
|
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
|
||||||
|
);
|
||||||
|
if (inlineAtts.length === 0) return;
|
||||||
|
|
||||||
|
// Seed the ref synchronously so a fast Send still attaches the right blobs
|
||||||
|
// even if the FileReader work below hasn't resolved yet.
|
||||||
|
for (const att of inlineAtts) {
|
||||||
|
if (!att.cid) continue;
|
||||||
|
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
|
||||||
|
inlineImagesRef.current.push({
|
||||||
|
cid: att.cid,
|
||||||
|
blobId: att.blobId,
|
||||||
|
type: att.type,
|
||||||
|
name: att.name || 'inline',
|
||||||
|
size: att.size,
|
||||||
|
dataUrl: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const updates = new Map<string, string>();
|
||||||
|
for (const att of inlineAtts) {
|
||||||
|
if (!att.cid) continue;
|
||||||
|
try {
|
||||||
|
const buffer = await composerClient.fetchBlobArrayBuffer(
|
||||||
|
att.blobId,
|
||||||
|
att.name || 'inline',
|
||||||
|
att.type,
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
const blob = new Blob([buffer], { type: att.type });
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
|
||||||
|
if (entry) entry.dataUrl = dataUrl;
|
||||||
|
updates.set(att.cid, dataUrl);
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('Failed to load inline image for compose', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled || updates.size === 0) return;
|
||||||
|
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// We deliberately hydrate once per composer open - subsequent replyTo
|
||||||
|
// object identity churn from parent renders shouldn't refetch.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [composerClient, plainTextMode, mode]);
|
||||||
|
|
||||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||||
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
|
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||||
: signatureIdentity?.textSignature
|
: signatureIdentity?.textSignature
|
||||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||||
: '';
|
: '';
|
||||||
@@ -870,8 +1012,8 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-save draft functionality
|
// Auto-save draft functionality
|
||||||
const saveDraft = async (): Promise<string | null> => {
|
const saveDraftOnce = async (): Promise<string | null> => {
|
||||||
if (!client) return null;
|
if (!client || !composerClient) return null;
|
||||||
|
|
||||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
@@ -896,7 +1038,7 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// Only save if data has changed
|
// Only save if data has changed
|
||||||
if (currentData === lastSavedDataRef.current) {
|
if (currentData === lastSavedDataRef.current) {
|
||||||
return draftId;
|
return draftIdRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
@@ -916,20 +1058,27 @@ export function EmailComposer({
|
|||||||
: (currentIdentity?.name || undefined);
|
: (currentIdentity?.name || undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedDraftId = await client.createDraft(
|
const previousDraftId = draftIdRef.current;
|
||||||
|
// Use the JMAP client and raw identity id for the *owning* account
|
||||||
|
// - falls back to active client for single-account / same-account
|
||||||
|
// identities. See `composerClient` derivation above.
|
||||||
|
const savedDraftId = await composerClient.createDraft(
|
||||||
toAddresses,
|
toAddresses,
|
||||||
subject || t('no_subject'),
|
subject || t('no_subject'),
|
||||||
plainTextMode ? body : htmlToPlainText(body),
|
plainTextMode ? body : htmlToPlainText(body),
|
||||||
ccAddresses,
|
ccAddresses,
|
||||||
bccAddresses,
|
bccAddresses,
|
||||||
currentIdentity?.id,
|
currentIdentityRawId,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
draftId || undefined,
|
previousDraftId || undefined,
|
||||||
uploadedAttachments,
|
uploadedAttachments,
|
||||||
fromName,
|
fromName,
|
||||||
plainTextMode ? undefined : body
|
plainTextMode ? undefined : body
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update the ref synchronously so a queued save sees the new id and
|
||||||
|
// doesn't try to destroy the just-replaced draft.
|
||||||
|
draftIdRef.current = savedDraftId;
|
||||||
setDraftId(savedDraftId);
|
setDraftId(savedDraftId);
|
||||||
lastSavedDataRef.current = currentData;
|
lastSavedDataRef.current = currentData;
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
@@ -946,6 +1095,28 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Serialize saves: each call waits for the previous in-flight save before
|
||||||
|
// running. This prevents the autosave timer and the send button from
|
||||||
|
// racing two `Email/set { destroy, create }` requests against the same
|
||||||
|
// draftId, which left orphan drafts and (when EmailSubmission failed)
|
||||||
|
// looked like "send didn't happen" (#303).
|
||||||
|
const saveDraft = (): Promise<string | null> => {
|
||||||
|
const previous = inflightSaveRef.current;
|
||||||
|
const promise = (async (): Promise<string | null> => {
|
||||||
|
if (previous) {
|
||||||
|
try { await previous; } catch { /* prior failure already reported */ }
|
||||||
|
}
|
||||||
|
return saveDraftOnce();
|
||||||
|
})();
|
||||||
|
inflightSaveRef.current = promise;
|
||||||
|
promise.finally(() => {
|
||||||
|
if (inflightSaveRef.current === promise) {
|
||||||
|
inflightSaveRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
// Keep saveDraftRef pointing to latest saveDraft
|
// Keep saveDraftRef pointing to latest saveDraft
|
||||||
saveDraftRef.current = saveDraft;
|
saveDraftRef.current = saveDraft;
|
||||||
|
|
||||||
@@ -963,6 +1134,10 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// Set new timeout for auto-save (2 seconds after last change)
|
// Set new timeout for auto-save (2 seconds after last change)
|
||||||
saveTimeoutRef.current = setTimeout(() => {
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
// Clear the ref so handleSend can distinguish "save scheduled" from
|
||||||
|
// "save in flight" - the former still needs flushing, the latter is
|
||||||
|
// tracked via inflightSaveRef.
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||||
emailHooks.onDraftChange.emit({
|
emailHooks.onDraftChange.emit({
|
||||||
to: to.split(',').map(s => s.trim()).filter(Boolean),
|
to: to.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
@@ -1086,14 +1261,28 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalDraftId = draftId;
|
// Resolve the freshest draftId we can. Two cases:
|
||||||
|
// 1. An autosave is currently in flight - wait for it; don't issue a
|
||||||
|
// parallel destroy/create that would race with it on the same id.
|
||||||
|
// 2. A debounced save is scheduled (timer set) - cancel it and flush
|
||||||
|
// now so the latest body content lands on the server.
|
||||||
|
// Use draftIdRef (not the React state) because state updates from
|
||||||
|
// the in-flight save may not have rendered yet when we read here.
|
||||||
|
let finalDraftId = draftIdRef.current;
|
||||||
|
if (inflightSaveRef.current) {
|
||||||
|
try {
|
||||||
|
const savedId = await inflightSaveRef.current;
|
||||||
|
if (savedId) finalDraftId = savedId;
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('In-flight draft save failed before send:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
try {
|
try {
|
||||||
const savedId = await saveDraft();
|
const savedId = await saveDraft();
|
||||||
if (savedId) {
|
if (savedId) finalDraftId = savedId;
|
||||||
finalDraftId = savedId;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error('Failed to save draft before send:', err);
|
debug.error('Failed to save draft before send:', err);
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1295,7 @@ export function EmailComposer({
|
|||||||
: undefined;
|
: undefined;
|
||||||
// When the user has typed a From override, that becomes the header From
|
// When the user has typed a From override, that becomes the header From
|
||||||
// (and MIME-builder From in the S/MIME path). The identity still drives
|
// (and MIME-builder From in the S/MIME path). The identity still drives
|
||||||
// the SMTP envelope MAIL FROM — set explicitly so it doesn't mistakenly
|
// the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
|
||||||
// default to the override address.
|
// default to the override address.
|
||||||
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
|
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
|
||||||
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
|
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
|
||||||
@@ -1128,7 +1317,7 @@ export function EmailComposer({
|
|||||||
if (signatureAlreadyInBody) return '';
|
if (signatureAlreadyInBody) return '';
|
||||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||||
if (signatureIdentity?.htmlSignature) {
|
if (signatureIdentity?.htmlSignature) {
|
||||||
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||||
}
|
}
|
||||||
if (signatureIdentity?.textSignature) {
|
if (signatureIdentity?.textSignature) {
|
||||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||||
@@ -1176,6 +1365,13 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||||
|
// S/MIME keys are scoped to one JMAP account's identity - sending
|
||||||
|
// from a cross-account identity via S/MIME would mix accounts'
|
||||||
|
// certs/clients. Refuse upfront and tell the user to switch.
|
||||||
|
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
|
||||||
|
if (crossAccount.localAccountId) {
|
||||||
|
throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.');
|
||||||
|
}
|
||||||
// 1. Resolve S/MIME key
|
// 1. Resolve S/MIME key
|
||||||
if (smimeSign_ && !smimeKeyRecord) {
|
if (smimeSign_ && !smimeKeyRecord) {
|
||||||
throw new Error('No S/MIME key bound to this identity');
|
throw new Error('No S/MIME key bound to this identity');
|
||||||
@@ -1184,7 +1380,7 @@ export function EmailComposer({
|
|||||||
// would produce a signature whose Subject differs from the visible
|
// would produce a signature whose Subject differs from the visible
|
||||||
// From, which most clients reject or flag. Refuse up front.
|
// From, which most clients reject or flag. Refuse up front.
|
||||||
if (overrideActive) {
|
if (overrideActive) {
|
||||||
throw new Error('Cannot use From override with S/MIME — disable one to send.');
|
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ensure key is unlocked for signing
|
// 2. Ensure key is unlocked for signing
|
||||||
@@ -1321,6 +1517,15 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
||||||
|
|
||||||
|
// Strip the cross-account namespace from the identity id before
|
||||||
|
// handing it to the parent - the JMAP server only knows the raw
|
||||||
|
// id. The owning local account travels alongside so the parent
|
||||||
|
// can route the send through the right client.
|
||||||
|
const rawIdentityId = outgoing.identityId || currentIdentity?.id;
|
||||||
|
const { localAccountId: identityLocalAccountId, rawId } = rawIdentityId
|
||||||
|
? stripCrossAccountIdentityPrefix(rawIdentityId)
|
||||||
|
: { localAccountId: null, rawId: undefined };
|
||||||
|
|
||||||
await onSend?.({
|
await onSend?.({
|
||||||
to: outgoing.to,
|
to: outgoing.to,
|
||||||
cc: outgoing.cc,
|
cc: outgoing.cc,
|
||||||
@@ -1331,8 +1536,9 @@ export function EmailComposer({
|
|||||||
draftId: finalDraftId || undefined,
|
draftId: finalDraftId || undefined,
|
||||||
fromEmail,
|
fromEmail,
|
||||||
fromName,
|
fromName,
|
||||||
identityId: outgoing.identityId || currentIdentity?.id,
|
identityId: rawId,
|
||||||
envelopeMailFrom,
|
envelopeMailFrom,
|
||||||
|
localAccountId: identityLocalAccountId ?? undefined,
|
||||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||||
inReplyTo: threadingHeaders?.inReplyTo,
|
inReplyTo: threadingHeaders?.inReplyTo,
|
||||||
references: threadingHeaders?.references,
|
references: threadingHeaders?.references,
|
||||||
@@ -1356,6 +1562,7 @@ export function EmailComposer({
|
|||||||
setBcc("");
|
setBcc("");
|
||||||
setSubject("");
|
setSubject("");
|
||||||
setBody("");
|
setBody("");
|
||||||
|
draftIdRef.current = null;
|
||||||
setDraftId(null);
|
setDraftId(null);
|
||||||
setSubAddressTag("");
|
setSubAddressTag("");
|
||||||
setValidationErrors({});
|
setValidationErrors({});
|
||||||
@@ -1501,16 +1708,31 @@ export function EmailComposer({
|
|||||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||||
>
|
>
|
||||||
{identities.map((identity) => {
|
{identityGroups.length > 0
|
||||||
const displayEmail = subAddressTag
|
? identityGroups.map((group) => (
|
||||||
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
<optgroup key={group.localAccountId} label={group.accountLabel}>
|
||||||
: identity.email;
|
{group.identities.map((identity) => {
|
||||||
return (
|
const displayEmail = subAddressTag
|
||||||
<option key={identity.id} value={identity.id}>
|
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||||
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
: identity.email;
|
||||||
</option>
|
return (
|
||||||
);
|
<option key={identity.id} value={identity.id}>
|
||||||
})}
|
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</optgroup>
|
||||||
|
))
|
||||||
|
: identities.map((identity) => {
|
||||||
|
const displayEmail = subAddressTag
|
||||||
|
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
|
||||||
|
: identity.email;
|
||||||
|
return (
|
||||||
|
<option key={identity.id} value={identity.id}>
|
||||||
|
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-foreground flex-1 truncate">
|
<span className="text-sm text-foreground flex-1 truncate">
|
||||||
@@ -1793,7 +2015,7 @@ export function EmailComposer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom toolbar */}
|
{/* Bottom toolbar */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
|
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
|
||||||
{/* Left side actions */}
|
{/* Left side actions */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface EmailListItemProps {
|
|||||||
email: Email;
|
email: Email;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
@@ -30,7 +31,7 @@ interface EmailListItemProps {
|
|||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
@@ -125,6 +126,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
onClick?.();
|
onClick?.();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||||
|
if (!onDoubleClick) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onDoubleClick();
|
||||||
|
}}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
@@ -296,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
|||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: "text-muted-foreground/80"
|
: "text-muted-foreground/80"
|
||||||
)}>
|
)}>
|
||||||
{trimmedPreview || "No preview available"}
|
{trimmedPreview || t('no_preview_available')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface EmailListProps {
|
|||||||
emails: Email[];
|
emails: Email[];
|
||||||
selectedEmailId?: string;
|
selectedEmailId?: string;
|
||||||
onEmailSelect?: (email: Email) => void;
|
onEmailSelect?: (email: Email) => void;
|
||||||
|
onEmailDoubleClick?: (email: Email) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
@@ -44,6 +45,7 @@ export function EmailList({
|
|||||||
emails,
|
emails,
|
||||||
selectedEmailId,
|
selectedEmailId,
|
||||||
onEmailSelect,
|
onEmailSelect,
|
||||||
|
onEmailDoubleClick,
|
||||||
className,
|
className,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
@@ -447,6 +449,7 @@ export function EmailList({
|
|||||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||||
|
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||||
onContextMenu={openContextMenu}
|
onContextMenu={openContextMenu}
|
||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||||
|
|||||||
+447
-357
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
PenSquare,
|
PenSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
@@ -78,10 +79,11 @@ import { EmailIdentityBadge } from "./email-identity-badge";
|
|||||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||||
import { useTour } from "@/components/tour/tour-provider";
|
import { useTour } from "@/components/tour/tour-provider";
|
||||||
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||||
import { RecipientPopover } from "./recipient-popover";
|
import { RecipientPopover } from "./recipient-popover";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
||||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||||
import { detectSmime } from "@/lib/smime/smime-detect";
|
import { detectSmime } from "@/lib/smime/smime-detect";
|
||||||
import { smimeDecrypt, SmimeKeyLockedError, normalizeCmsBytes } from "@/lib/smime/smime-decrypt";
|
import { smimeDecrypt, SmimeKeyLockedError, normalizeCmsBytes } from "@/lib/smime/smime-decrypt";
|
||||||
@@ -92,7 +94,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import type { TnefAttachment } from "@/lib/tnef";
|
import type { TnefAttachment } from "@/lib/tnef";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||||
@@ -921,6 +923,7 @@ export function EmailViewer({
|
|||||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const { startTour } = useTour();
|
const { startTour } = useTour();
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||||
@@ -970,8 +973,8 @@ export function EmailViewer({
|
|||||||
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
||||||
|
|
||||||
// Plugin detail sidebar state
|
// Plugin detail sidebar state
|
||||||
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
|
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
|
||||||
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
|
const hasDetailSidebar = detailSlots.length > 0;
|
||||||
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
|
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
|
||||||
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
|
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
|
||||||
const detailSidebarWidthRef = useRef(280);
|
const detailSidebarWidthRef = useRef(280);
|
||||||
@@ -1137,9 +1140,34 @@ export function EmailViewer({
|
|||||||
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
|
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
|
||||||
const contacts = useContactStore((s) => s.contacts);
|
const contacts = useContactStore((s) => s.contacts);
|
||||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||||
if (isMobileDevice) return; // no sidebar on mobile
|
if (isMobileDevice) {
|
||||||
|
// No room for a sidebar on mobile - send the user to the contacts page
|
||||||
|
// with params describing what to show. The `from=email` flag turns the
|
||||||
|
// page's mobile back button into a router.back() that returns here.
|
||||||
|
const allRecipients = [
|
||||||
|
...(email?.from || []),
|
||||||
|
...(email?.to || []),
|
||||||
|
...(email?.cc || []),
|
||||||
|
...(email?.bcc || []),
|
||||||
|
...(email?.replyTo || []),
|
||||||
|
];
|
||||||
|
const recipientName = allRecipients.find(
|
||||||
|
(r) => r.email.toLowerCase() === recipientEmail.toLowerCase()
|
||||||
|
)?.name;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (contact) {
|
||||||
|
params.set('contactId', contact.id);
|
||||||
|
} else {
|
||||||
|
params.set('addEmail', recipientEmail);
|
||||||
|
if (recipientName) params.set('addName', recipientName);
|
||||||
|
}
|
||||||
|
params.set('from', 'email');
|
||||||
|
router.push(`/contacts?${params.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setContactSidebarEmail(recipientEmail);
|
setContactSidebarEmail(recipientEmail);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2489,7 +2517,7 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: `<p style="color: var(--color-muted-foreground); font-style: italic;">${t('no_body_content')}</p>`,
|
||||||
isHtml: false,
|
isHtml: false,
|
||||||
hasStyleTag: false,
|
hasStyleTag: false,
|
||||||
};
|
};
|
||||||
@@ -2497,7 +2525,7 @@ export function EmailViewer({
|
|||||||
// toggling permission imperatively unblocks content via restoreBlockedContent
|
// toggling permission imperatively unblocks content via restoreBlockedContent
|
||||||
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
|
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [email, externalContentPolicy, cidBlobUrls]);
|
}, [email, externalContentPolicy, cidBlobUrls, t]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -2535,7 +2563,12 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
// Blob URLs inherit our origin; script-bearing MIME types (text/html,
|
||||||
|
// image/svg+xml, etc.) would execute as the webmail origin if opened
|
||||||
|
// top-level. Force the download path for anything not on the inert allowlist.
|
||||||
|
const opensPreview = isPreviewable
|
||||||
|
&& mailAttachmentAction === 'preview'
|
||||||
|
&& isMimeTypeSafeForInlinePreview(attachment.type);
|
||||||
|
|
||||||
const info: AttachmentInfo = {
|
const info: AttachmentInfo = {
|
||||||
name: attachment.name || '',
|
name: attachment.name || '',
|
||||||
@@ -2740,8 +2773,8 @@ export function EmailViewer({
|
|||||||
// i.e. light-on-light. The second rule disables filter on bgcolor-like
|
// i.e. light-on-light. The second rule disables filter on bgcolor-like
|
||||||
// elements that are descendants of another bgcolor-like element.
|
// elements that are descendants of another bgcolor-like element.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
html { background: #1a1a1a; }
|
html { background: #121212; }
|
||||||
body { filter: invert(1) hue-rotate(180deg); }
|
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
|
||||||
img, video, svg, canvas, object, embed, input[type="image"] {
|
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
@@ -2776,10 +2809,20 @@ export function EmailViewer({
|
|||||||
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
|
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
|
// Defense-in-depth CSP inside srcDoc: even if the sanitizer ever lets a
|
||||||
|
// <script> tag through, the iframe document forbids script execution
|
||||||
|
// (default-src 'none'). img/style/font remain permissive to match what the
|
||||||
|
// sanitizer is allowed to emit and what the host already permits when
|
||||||
|
// external content is loaded.
|
||||||
|
const iframeCsp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||||
<style>
|
<style>
|
||||||
|
html, body { overflow: hidden; }
|
||||||
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
|
@media (max-width: 640px) { body { padding-left: 0; padding-right: 0; } }
|
||||||
img { max-width: 100% !important; height: auto !important; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
a { color: #1a73e8; }
|
a { color: #1a73e8; }
|
||||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||||
@@ -2854,7 +2897,10 @@ export function EmailViewer({
|
|||||||
// True while the new email's body is still being fetched. Catches the
|
// True while the new email's body is still being fetched. Catches the
|
||||||
// window between selectedEmail changing and isLoading flipping true, so the
|
// window between selectedEmail changing and isLoading flipping true, so the
|
||||||
// quick reply / body don't flicker through a partial render.
|
// quick reply / body don't flicker through a partial render.
|
||||||
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
|
// An empty bodyValues with no referenced parts means the email has no body
|
||||||
|
// (e.g. calendar-only invites) - not "still loading".
|
||||||
|
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
|
||||||
|
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
|
||||||
|
|
||||||
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||||
// it doesn't flash in below a still-resizing iframe.
|
// it doesn't flash in below a still-resizing iframe.
|
||||||
@@ -3050,14 +3096,26 @@ export function EmailViewer({
|
|||||||
if (!email) return;
|
if (!email) return;
|
||||||
const printSender = email.from?.[0];
|
const printSender = email.from?.[0];
|
||||||
const date = email.sentAt ? formatDateTime(email.sentAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
const date = email.sentAt ? formatDateTime(email.sentAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
||||||
const toList = email.to?.map(r => r.name ? `${r.name} <${r.email}>` : r.email).join(', ') || '';
|
const formatRecipient = (r: { name?: string | null; email: string }) =>
|
||||||
const ccList = email.cc?.map(r => r.name ? `${r.name} <${r.email}>` : r.email).join(', ') || '';
|
r.name ? `${escapeHtml(r.name)} <${escapeHtml(r.email)}>` : escapeHtml(r.email);
|
||||||
|
const toList = email.to?.map(formatRecipient).join(', ') || '';
|
||||||
|
const ccList = email.cc?.map(formatRecipient).join(', ') || '';
|
||||||
|
const subjectText = email.subject || t('no_subject');
|
||||||
|
const senderText = printSender?.name
|
||||||
|
? `${printSender.name} <${printSender.email}>`
|
||||||
|
: printSender?.email || t('unknown_sender');
|
||||||
|
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
|
||||||
|
// <style>. The print window has no iframe isolation, so re-sanitize with
|
||||||
|
// the stricter config that forbids <style> before injecting into the DOM.
|
||||||
|
const printableBody = effectiveEmailContent.isHtml
|
||||||
|
? sanitizeEmailHtml(effectiveEmailContent.html)
|
||||||
|
: effectiveEmailContent.html;
|
||||||
|
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open('', '_blank');
|
||||||
if (!printWindow) return;
|
if (!printWindow) return;
|
||||||
|
|
||||||
printWindow.document.write(`<!DOCTYPE html>
|
printWindow.document.write(`<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><title>${DOMPurify.sanitize(email.subject || t('no_subject'))}</title>
|
<html><head><meta charset="utf-8"><title>${escapeHtml(subjectText)}</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px; color: #000; }
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px; color: #000; }
|
||||||
.header { border-bottom: 1px solid #ccc; padding-bottom: 16px; margin-bottom: 16px; }
|
.header { border-bottom: 1px solid #ccc; padding-bottom: 16px; margin-bottom: 16px; }
|
||||||
@@ -3069,15 +3127,15 @@ export function EmailViewer({
|
|||||||
@media print { body { margin: 20px; } }
|
@media print { body { margin: 20px; } }
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="subject">${DOMPurify.sanitize(email.subject || t('no_subject'))}</div>
|
<div class="subject">${escapeHtml(subjectText)}</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<div><strong>${t('from')}:</strong> ${DOMPurify.sanitize(printSender?.name ? `${printSender.name} <${printSender.email}>` : printSender?.email || t('unknown_sender'))}</div>
|
<div><strong>${escapeHtml(t('from'))}:</strong> ${escapeHtml(senderText)}</div>
|
||||||
${toList ? `<div><strong>${t('to')}:</strong> ${toList}</div>` : ''}
|
${toList ? `<div><strong>${escapeHtml(t('to'))}:</strong> ${toList}</div>` : ''}
|
||||||
${ccList ? `<div><strong>CC:</strong> ${ccList}</div>` : ''}
|
${ccList ? `<div><strong>CC:</strong> ${ccList}</div>` : ''}
|
||||||
${date ? `<div><strong>${t('date')}:</strong> ${DOMPurify.sanitize(date)}</div>` : ''}
|
${date ? `<div><strong>${escapeHtml(t('date'))}:</strong> ${escapeHtml(date)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="body">${effectiveEmailContent.html}</div>
|
<div class="body">${printableBody}</div>
|
||||||
</body></html>`);
|
</body></html>`);
|
||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
printWindow.focus();
|
printWindow.focus();
|
||||||
@@ -3197,19 +3255,21 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||||
<div className="text-center p-8">
|
{!isEmbedded && (
|
||||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
<div className="text-center p-8">
|
||||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||||
|
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
||||||
|
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
||||||
|
{onCompose && (
|
||||||
|
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
||||||
|
<PenSquare className="w-4 h-4 mr-2" />
|
||||||
|
{t('compose')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
|
)}
|
||||||
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
|
|
||||||
{onCompose && (
|
|
||||||
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
|
|
||||||
<PenSquare className="w-4 h-4 mr-2" />
|
|
||||||
{t('compose')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4028,7 +4088,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Content Area */}
|
{/* Email Content Area */}
|
||||||
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-16")}>
|
<div className={cn("flex-1 overflow-auto overscroll-contain bg-muted/30", isMobile && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
|
||||||
|
<div className="min-h-full flex flex-col">
|
||||||
|
|
||||||
{/* === SENDER INFO (Desktop) === */}
|
{/* === SENDER INFO (Desktop) === */}
|
||||||
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
@@ -4052,13 +4113,16 @@ export function EmailViewer({
|
|||||||
<div>
|
<div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
{sender?.email ? (
|
||||||
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
|
<RecipientPopover
|
||||||
className="font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
|
name={sender?.name}
|
||||||
title={t('view_contact')}
|
email={sender.email}
|
||||||
>
|
onViewContact={handleViewContactSidebar}
|
||||||
{sender?.name || sender?.email || t('unknown_sender')}
|
className="font-semibold text-left"
|
||||||
</button>
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-foreground">{t('unknown_sender')}</span>
|
||||||
|
)}
|
||||||
<EmailIdentityBadge email={email} identities={identities} />
|
<EmailIdentityBadge email={email} identities={identities} />
|
||||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||||
<UnsubscribeBanner
|
<UnsubscribeBanner
|
||||||
@@ -4134,310 +4198,6 @@ export function EmailViewer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable Details */}
|
|
||||||
{showFullHeaders && (() => {
|
|
||||||
const translateAuthResult = (result?: string) => {
|
|
||||||
const r = (result || '').toLowerCase();
|
|
||||||
switch (r) {
|
|
||||||
case 'pass': return t('authentication.result.pass');
|
|
||||||
case 'fail': return t('authentication.result.fail');
|
|
||||||
case 'softfail': return t('authentication.result.softfail');
|
|
||||||
case 'neutral': return t('authentication.result.neutral');
|
|
||||||
case 'permerror': return t('authentication.result.permerror');
|
|
||||||
case 'temperror': return t('authentication.result.temperror');
|
|
||||||
case 'none': return t('authentication.result.none');
|
|
||||||
default: return result || '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const replyToDifferent = !!email.replyTo?.length &&
|
|
||||||
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
|
|
||||||
const deliveryDeltaMs = email.sentAt && email.receivedAt
|
|
||||||
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
|
|
||||||
: 0;
|
|
||||||
const formatDelta = (diff: number) => {
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
const dayUnit = days > 1 ? t('time.days') : t('time.day');
|
|
||||||
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
|
|
||||||
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
|
|
||||||
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
|
|
||||||
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
|
|
||||||
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
|
|
||||||
return `${minutes} ${minuteUnitSingle}`;
|
|
||||||
};
|
|
||||||
const fullDate = (iso?: string) => iso
|
|
||||||
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
|
|
||||||
: '-';
|
|
||||||
const auth = email.authenticationResults;
|
|
||||||
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
|
|
||||||
const topMimeType = email.bodyStructure?.type;
|
|
||||||
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
|
|
||||||
<dd className={cn(
|
|
||||||
"text-sm text-foreground min-w-0 break-words",
|
|
||||||
mono && "font-mono text-xs",
|
|
||||||
)}>{children}</dd>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => {
|
|
||||||
if (!result) return null;
|
|
||||||
const status = getSecurityStatus(result);
|
|
||||||
const Icon = status.icon === 'check' ? Check
|
|
||||||
: status.icon === 'x' ? X
|
|
||||||
: status.icon === 'alert' ? AlertTriangle
|
|
||||||
: Minus;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
|
||||||
tooltip && "cursor-help",
|
|
||||||
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
|
|
||||||
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
|
|
||||||
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
|
|
||||||
status.icon === 'minus' && "bg-muted/40 border-border",
|
|
||||||
)}
|
|
||||||
title={tooltip}
|
|
||||||
>
|
|
||||||
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
|
|
||||||
<span className="font-medium text-foreground">{name}</span>
|
|
||||||
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
|
|
||||||
{translateAuthResult(result)}
|
|
||||||
</span>
|
|
||||||
{extra && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/50">·</span>
|
|
||||||
<span className="text-muted-foreground">{extra}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
|
|
||||||
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
|
|
||||||
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 pt-3 border-t border-border grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
|
|
||||||
<section>
|
|
||||||
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
<Row label={t('from')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
<RecipientPopover
|
|
||||||
name={sender?.name}
|
|
||||||
email={sender?.email || ''}
|
|
||||||
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
|
|
||||||
onViewContact={handleViewContactSidebar}
|
|
||||||
className="text-sm text-left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
{replyToDifferent && (
|
|
||||||
<Row label={t('reply_to_label').replace(':', '')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{email.replyTo!.map((r, i) => (
|
|
||||||
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.to && email.to.length > 0 && (
|
|
||||||
<Row label={t('to')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.cc && email.cc.length > 0 && (
|
|
||||||
<Row label={t('cc')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.bcc && email.bcc.length > 0 && (
|
|
||||||
<Row label={t('bcc')}>
|
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.sentAt && (
|
|
||||||
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
|
|
||||||
)}
|
|
||||||
<Row label={t('details.received')}>
|
|
||||||
{fullDate(email.receivedAt)}
|
|
||||||
{deliveryDeltaMs > 60000 && (
|
|
||||||
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{hasAuthSection && (
|
|
||||||
<section>
|
|
||||||
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{auth?.spf && (
|
|
||||||
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
|
||||||
)}
|
|
||||||
{auth?.dkim && (
|
|
||||||
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
|
||||||
)}
|
|
||||||
{auth?.dmarc && (
|
|
||||||
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
|
|
||||||
)}
|
|
||||||
{auth?.iprev && (
|
|
||||||
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
|
|
||||||
)}
|
|
||||||
{email.spamScore !== undefined && (
|
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
|
||||||
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
|
|
||||||
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
|
|
||||||
"bg-green-500/[0.07] border-green-500/30",
|
|
||||||
)}>
|
|
||||||
<Shield className={cn(
|
|
||||||
"w-3.5 h-3.5",
|
|
||||||
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
|
|
||||||
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
|
|
||||||
"text-green-700 dark:text-green-400",
|
|
||||||
)} />
|
|
||||||
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
|
|
||||||
<span className={cn(
|
|
||||||
"text-[10px] uppercase tracking-wider",
|
|
||||||
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
|
|
||||||
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
|
|
||||||
"text-green-700 dark:text-green-400",
|
|
||||||
)}>
|
|
||||||
{email.spamScore.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
{email.spamStatus && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/50">·</span>
|
|
||||||
<span className="text-muted-foreground">{email.spamStatus}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{email.spamLLM && (
|
|
||||||
<div className="mt-2 flex items-start gap-2 text-sm">
|
|
||||||
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
|
|
||||||
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
|
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className={cn(
|
|
||||||
"font-medium",
|
|
||||||
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
|
|
||||||
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
|
|
||||||
"text-amber-700 dark:text-amber-400",
|
|
||||||
)}>
|
|
||||||
{email.spamLLM.verdict}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasIdentifiers && (
|
|
||||||
<section>
|
|
||||||
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{email.messageId && (
|
|
||||||
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
|
|
||||||
)}
|
|
||||||
{email.inReplyTo && email.inReplyTo.length > 0 && (
|
|
||||||
<Row label={t('details.in_reply_to')} mono>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.references && email.references.length > 0 && (
|
|
||||||
<Row label={t('details.references')}>
|
|
||||||
<details className="group">
|
|
||||||
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
|
|
||||||
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
|
|
||||||
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
|
|
||||||
</summary>
|
|
||||||
<div className="mt-1 space-y-0.5 font-mono text-xs">
|
|
||||||
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.threadId && (
|
|
||||||
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<SectionHeader>{t('details.message_properties')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{email.subject !== undefined && (
|
|
||||||
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
|
||||||
)}
|
|
||||||
<Row label={t('details.size')}>
|
|
||||||
{formatFileSize(email.size)}
|
|
||||||
{topMimeType && (
|
|
||||||
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
{effectiveAttachments.length > 0 && (
|
|
||||||
<Row label={t('attachments')}>
|
|
||||||
{t('details.attachments_summary', {
|
|
||||||
count: effectiveAttachments.length,
|
|
||||||
size: formatFileSize(totalAttachmentSize),
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{email.accountLabel && (
|
|
||||||
<Row label={t('details.account')}>{email.accountLabel}</Row>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{hasListInfo && (
|
|
||||||
<section className="lg:col-span-2">
|
|
||||||
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
|
|
||||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
|
||||||
{listHeaders?.listId && (
|
|
||||||
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
|
|
||||||
)}
|
|
||||||
{listHeaders?.listUnsubscribe?.preferred && (
|
|
||||||
<Row label={t('details.list_unsubscribe')}>
|
|
||||||
<span className="break-all">
|
|
||||||
{listHeaders.listUnsubscribe.preferred === 'http'
|
|
||||||
? listHeaders.listUnsubscribe.http
|
|
||||||
: listHeaders.listUnsubscribe.mailto}
|
|
||||||
</span>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
{listHeaders?.listHelp && (
|
|
||||||
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
|
|
||||||
)}
|
|
||||||
{listHeaders?.listPost && (
|
|
||||||
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/* Attachments on the right (beside-sender mode) */}
|
{/* Attachments on the right (beside-sender mode) */}
|
||||||
@@ -4597,12 +4357,16 @@ export function EmailViewer({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Row 1: Sender name + badges */}
|
{/* Row 1: Sender name + badges */}
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<button
|
{sender?.email ? (
|
||||||
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
|
<RecipientPopover
|
||||||
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
|
name={sender?.name}
|
||||||
>
|
email={sender.email}
|
||||||
{sender?.name || sender?.email || t('unknown_sender')}
|
onViewContact={handleViewContactSidebar}
|
||||||
</button>
|
className="text-sm font-semibold text-left"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
|
||||||
|
)}
|
||||||
<EmailIdentityBadge email={email} identities={identities} />
|
<EmailIdentityBadge email={email} identities={identities} />
|
||||||
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
|
||||||
<UnsubscribeBanner
|
<UnsubscribeBanner
|
||||||
@@ -4639,6 +4403,22 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
{showFullHeaders ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
{t('hide_details')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
{t('show_details')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Date/time + size on the right (mobile) */}
|
{/* Date/time + size on the right (mobile) */}
|
||||||
@@ -4655,6 +4435,313 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Details (shared across mobile/tablet/desktop) */}
|
||||||
|
{showFullHeaders && (() => {
|
||||||
|
const translateAuthResult = (result?: string) => {
|
||||||
|
const r = (result || '').toLowerCase();
|
||||||
|
switch (r) {
|
||||||
|
case 'pass': return t('authentication.result.pass');
|
||||||
|
case 'fail': return t('authentication.result.fail');
|
||||||
|
case 'softfail': return t('authentication.result.softfail');
|
||||||
|
case 'neutral': return t('authentication.result.neutral');
|
||||||
|
case 'permerror': return t('authentication.result.permerror');
|
||||||
|
case 'temperror': return t('authentication.result.temperror');
|
||||||
|
case 'none': return t('authentication.result.none');
|
||||||
|
default: return result || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const replyToDifferent = !!email.replyTo?.length &&
|
||||||
|
(!email.from || email.replyTo[0].email !== email.from[0]?.email);
|
||||||
|
const deliveryDeltaMs = email.sentAt && email.receivedAt
|
||||||
|
? Math.abs(new Date(email.receivedAt).getTime() - new Date(email.sentAt).getTime())
|
||||||
|
: 0;
|
||||||
|
const formatDelta = (diff: number) => {
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const dayUnit = days > 1 ? t('time.days') : t('time.day');
|
||||||
|
const hourUnit = (hours % 24) > 1 ? t('time.hours') : t('time.hour');
|
||||||
|
const minuteUnit = (minutes % 60) > 1 ? t('time.minutes') : t('time.minute');
|
||||||
|
const minuteUnitSingle = minutes > 1 ? t('time.minutes') : t('time.minute');
|
||||||
|
if (days > 0) return `${days} ${dayUnit} ${hours % 24} ${hourUnit}`;
|
||||||
|
if (hours > 0) return `${hours} ${hourUnit} ${minutes % 60} ${minuteUnit}`;
|
||||||
|
return `${minutes} ${minuteUnitSingle}`;
|
||||||
|
};
|
||||||
|
const fullDate = (iso?: string) => iso
|
||||||
|
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
|
||||||
|
: '-';
|
||||||
|
const auth = email.authenticationResults;
|
||||||
|
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
|
||||||
|
const topMimeType = email.bodyStructure?.type;
|
||||||
|
const SectionHeader = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div className="text-[10px] font-semibold tracking-wider text-muted-foreground uppercase mb-1.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
|
||||||
|
<dd className={cn(
|
||||||
|
"text-sm text-foreground min-w-0 break-words",
|
||||||
|
mono && "font-mono text-xs",
|
||||||
|
)}>{children}</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const AuthChip = ({ name, result, extra, tooltip }: { name: string; result?: string; extra?: React.ReactNode; tooltip?: string }) => {
|
||||||
|
if (!result) return null;
|
||||||
|
const status = getSecurityStatus(result);
|
||||||
|
const Icon = status.icon === 'check' ? Check
|
||||||
|
: status.icon === 'x' ? X
|
||||||
|
: status.icon === 'alert' ? AlertTriangle
|
||||||
|
: Minus;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||||
|
tooltip && "cursor-help",
|
||||||
|
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
|
||||||
|
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
|
||||||
|
status.icon === 'alert' && "bg-amber-500/[0.07] border-amber-500/30",
|
||||||
|
status.icon === 'minus' && "bg-muted/40 border-border",
|
||||||
|
)}
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
|
||||||
|
<span className="font-medium text-foreground">{name}</span>
|
||||||
|
<span className={cn("text-[10px] uppercase tracking-wider", status.color)}>
|
||||||
|
{translateAuthResult(result)}
|
||||||
|
</span>
|
||||||
|
{extra && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">·</span>
|
||||||
|
<span className="text-muted-foreground">{extra}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasIdentifiers = !!(email.messageId || email.inReplyTo?.length || email.references?.length || email.threadId);
|
||||||
|
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
|
||||||
|
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
<Row label={t('from')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<RecipientPopover
|
||||||
|
name={sender?.name}
|
||||||
|
email={sender?.email || ''}
|
||||||
|
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
|
||||||
|
onViewContact={handleViewContactSidebar}
|
||||||
|
className="text-sm text-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{replyToDifferent && (
|
||||||
|
<Row label={t('reply_to_label').replace(':', '')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{email.replyTo!.map((r, i) => (
|
||||||
|
<RecipientPopover key={r.email + i} name={r.name} email={r.email} onViewContact={handleViewContactSidebar} className="text-sm" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.to && email.to.length > 0 && (
|
||||||
|
<Row label={t('to')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.cc && email.cc.length > 0 && (
|
||||||
|
<Row label={t('cc')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.bcc && email.bcc.length > 0 && (
|
||||||
|
<Row label={t('bcc')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.sentAt && (
|
||||||
|
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
|
||||||
|
)}
|
||||||
|
<Row label={t('details.received')}>
|
||||||
|
{fullDate(email.receivedAt)}
|
||||||
|
{deliveryDeltaMs > 60000 && (
|
||||||
|
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hasAuthSection && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{auth?.spf && (
|
||||||
|
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
||||||
|
)}
|
||||||
|
{auth?.dkim && (
|
||||||
|
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
||||||
|
)}
|
||||||
|
{auth?.dmarc && (
|
||||||
|
<AuthChip name="DMARC" result={auth.dmarc.result} extra={auth.dmarc.policy ? `${t('authentication.policy').toLowerCase()}: ${auth.dmarc.policy}` : undefined} tooltip={t('authentication.tooltip_dmarc')} />
|
||||||
|
)}
|
||||||
|
{auth?.iprev && (
|
||||||
|
<AuthChip name={t('details.iprev')} result={auth.iprev.result} extra={auth.iprev.ip} />
|
||||||
|
)}
|
||||||
|
{email.spamScore !== undefined && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||||
|
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
|
||||||
|
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
|
||||||
|
"bg-green-500/[0.07] border-green-500/30",
|
||||||
|
)}>
|
||||||
|
<Shield className={cn(
|
||||||
|
"w-3.5 h-3.5",
|
||||||
|
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
|
||||||
|
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
|
||||||
|
"text-green-700 dark:text-green-400",
|
||||||
|
)} />
|
||||||
|
<span className="font-medium text-foreground">{t('authentication.spam_score')}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] uppercase tracking-wider",
|
||||||
|
email.spamScore > 5 ? "text-red-700 dark:text-red-400" :
|
||||||
|
email.spamScore > 2 ? "text-amber-700 dark:text-amber-400" :
|
||||||
|
"text-green-700 dark:text-green-400",
|
||||||
|
)}>
|
||||||
|
{email.spamScore.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
{email.spamStatus && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">·</span>
|
||||||
|
<span className="text-muted-foreground">{email.spamStatus}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{email.spamLLM && (
|
||||||
|
<div className="mt-2 flex items-start gap-2 text-sm">
|
||||||
|
{email.spamLLM.verdict === 'LEGITIMATE' ? <Brain className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-700 dark:text-green-400" /> :
|
||||||
|
email.spamLLM.verdict === 'SPAM' ? <ShieldAlert className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" /> :
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-700 dark:text-amber-400" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className={cn(
|
||||||
|
"font-medium",
|
||||||
|
email.spamLLM.verdict === 'LEGITIMATE' ? "text-green-700 dark:text-green-400" :
|
||||||
|
email.spamLLM.verdict === 'SPAM' ? "text-red-700 dark:text-red-400" :
|
||||||
|
"text-amber-700 dark:text-amber-400",
|
||||||
|
)}>
|
||||||
|
{email.spamLLM.verdict}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground"> · {email.spamLLM.explanation}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasIdentifiers && (
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
{email.messageId && (
|
||||||
|
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
|
||||||
|
)}
|
||||||
|
{email.inReplyTo && email.inReplyTo.length > 0 && (
|
||||||
|
<Row label={t('details.in_reply_to')} mono>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{email.inReplyTo.map((id, i) => <div key={i} className="break-all">{id}</div>)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.references && email.references.length > 0 && (
|
||||||
|
<Row label={t('details.references')}>
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors list-none flex items-center gap-1">
|
||||||
|
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
|
||||||
|
{t(email.references.length === 1 ? 'previous_messages' : 'previous_messages_plural', { count: email.references.length })}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 space-y-0.5 font-mono text-xs">
|
||||||
|
{email.references.map((id, i) => <div key={i} className="break-all">{id}</div>)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.threadId && (
|
||||||
|
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="min-w-0">
|
||||||
|
<SectionHeader>{t('details.message_properties')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
{email.subject !== undefined && (
|
||||||
|
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
||||||
|
)}
|
||||||
|
<Row label={t('details.size')}>
|
||||||
|
{formatFileSize(email.size)}
|
||||||
|
{topMimeType && (
|
||||||
|
<span className="text-muted-foreground"> · <span className="font-mono text-xs">{topMimeType}</span></span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
{effectiveAttachments.length > 0 && (
|
||||||
|
<Row label={t('attachments')}>
|
||||||
|
{t('details.attachments_summary', {
|
||||||
|
count: effectiveAttachments.length,
|
||||||
|
size: formatFileSize(totalAttachmentSize),
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{email.accountLabel && (
|
||||||
|
<Row label={t('details.account')}>{email.accountLabel}</Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hasListInfo && (
|
||||||
|
<section className="lg:col-span-2 min-w-0">
|
||||||
|
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
|
||||||
|
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
{listHeaders?.listId && (
|
||||||
|
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
|
||||||
|
)}
|
||||||
|
{listHeaders?.listUnsubscribe?.preferred && (
|
||||||
|
<Row label={t('details.list_unsubscribe')}>
|
||||||
|
<span className="break-all">
|
||||||
|
{listHeaders.listUnsubscribe.preferred === 'http'
|
||||||
|
? listHeaders.listUnsubscribe.http
|
||||||
|
: listHeaders.listUnsubscribe.mailto}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{listHeaders?.listHelp && (
|
||||||
|
<Row label={t('details.list_help')}><span className="break-all">{listHeaders.listHelp}</span></Row>
|
||||||
|
)}
|
||||||
|
{listHeaders?.listPost && (
|
||||||
|
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* S/MIME Status Banner */}
|
{/* S/MIME Status Banner */}
|
||||||
{smimeStatus && (
|
{smimeStatus && (
|
||||||
<div className="border-b border-border bg-muted/30">
|
<div className="border-b border-border bg-muted/30">
|
||||||
@@ -5091,13 +5178,14 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="grow shrink-0 flex flex-col">
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"email-content-wrapper overflow-x-auto",
|
"email-content-wrapper overflow-x-auto",
|
||||||
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
|
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
|
||||||
)}>
|
)}
|
||||||
|
style={isDark ? { backgroundColor: '#121212' } : undefined}>
|
||||||
{isBodyLoading ? (
|
{isBodyLoading ? (
|
||||||
<div
|
<div
|
||||||
className="space-y-3 px-6 py-4 animate-pulse"
|
className="space-y-3 px-6 py-4 animate-pulse"
|
||||||
@@ -5115,14 +5203,15 @@ export function EmailViewer({
|
|||||||
srcDoc={emailIframeSrcDoc}
|
srcDoc={emailIframeSrcDoc}
|
||||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||||
title="Email content"
|
title="Email content"
|
||||||
className="w-full border-0"
|
className="w-full border-0 block"
|
||||||
|
scrolling="no"
|
||||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||||
onLoad={handleIframeLoad}
|
onLoad={handleIframeLoad}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="email-content-text text-foreground"
|
className="email-content-text text-foreground"
|
||||||
dangerouslySetInnerHTML={{ __html: effectiveEmailContent.html }}
|
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
@@ -5137,7 +5226,7 @@ export function EmailViewer({
|
|||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||||
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -5232,6 +5321,7 @@ export function EmailViewer({
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Email Source Modal */}
|
{/* Email Source Modal */}
|
||||||
{showSourceModal && email && (
|
{showSourceModal && email && (
|
||||||
@@ -5283,7 +5373,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Mobile bottom action bar */}
|
{/* Mobile bottom action bar */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden overflow-hidden">
|
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-background border-t border-border sm:hidden overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]">
|
||||||
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
|
<div className="flex items-center overflow-x-auto mobile-scroll-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={onNavigatePrev}
|
onClick={onNavigatePrev}
|
||||||
@@ -5433,4 +5523,4 @@ export function EmailViewer({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user