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 |
@@ -1,5 +1,96 @@
|
||||
# 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
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
} 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);
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
@@ -17,7 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
|
||||
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
@@ -45,6 +45,8 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
@@ -75,6 +77,13 @@ export default function CalendarPage() {
|
||||
const t = useTranslations("calendar");
|
||||
const tWebcalAction = useTranslations("calendar.webcal_action");
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
|
||||
// collapses into a burger-toggled overlay instead of taking inline space.
|
||||
const isNarrow = !isDesktop;
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -255,12 +264,17 @@ export default function CalendarPage() {
|
||||
return subscribeToPendingWebcal(openPendingWebcal);
|
||||
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
||||
|
||||
// Single-account fetch path. The Pro shell aggregates calendars from
|
||||
// every connected account via [[useProMultiAccountCalendars]] below, so
|
||||
// skip this fetch there to avoid clobbering the merged list with the
|
||||
// active client's calendars only.
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchCalendars(client);
|
||||
}
|
||||
}, [client, fetchCalendars]);
|
||||
}, [client, fetchCalendars, isEmbedded]);
|
||||
|
||||
// Auto-refresh iCal subscriptions
|
||||
useEffect(() => {
|
||||
@@ -329,10 +343,21 @@ export default function CalendarPage() {
|
||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
||||
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
|
||||
|
||||
// Pro shell only: aggregate calendars and events from every connected
|
||||
// account so the sidebar lists them all (and the views render their
|
||||
// events together). The hook is a no-op outside the embedded shell.
|
||||
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
|
||||
isEmbedded ? dateRange?.start ?? null : null,
|
||||
isEmbedded ? dateRange?.end ?? null : null,
|
||||
);
|
||||
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||
|
||||
const navigatePrev = useCallback(() => {
|
||||
let next: Date;
|
||||
@@ -404,6 +429,8 @@ export default function CalendarPage() {
|
||||
setMobileReturnToMonth(true);
|
||||
setViewMode("day");
|
||||
}
|
||||
// Close the narrow-pane sidebar overlay after the user picks a date.
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
|
||||
|
||||
const navigateBackToMonth = useCallback(() => {
|
||||
@@ -554,12 +581,15 @@ export default function CalendarPage() {
|
||||
}, [events, client]);
|
||||
|
||||
const refetchCurrentRange = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (!client || !activeAccountId) return;
|
||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||
if (currentRange) {
|
||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||
if (!currentRange) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
|
||||
return;
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh calendar data via JMAP instead of reloading the page.
|
||||
@@ -567,8 +597,11 @@ export default function CalendarPage() {
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||
: fetchCalendars(client);
|
||||
await Promise.all([
|
||||
fetchCalendars(client),
|
||||
calendarRefresh,
|
||||
refetchCurrentRange(),
|
||||
refreshAllSubscriptions(client),
|
||||
]);
|
||||
@@ -1217,11 +1250,11 @@ export default function CalendarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Left Navigation Rail */}
|
||||
{!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)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -1240,15 +1273,31 @@ export default function CalendarPage() {
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Sidebar - full height */}
|
||||
{!isMobile && !inlineApp && (
|
||||
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
|
||||
{isNarrow && narrowSidebarOpen && !inlineApp && (
|
||||
<div
|
||||
className={cn(
|
||||
"inset-0 bg-black/50 z-40",
|
||||
isEmbedded ? "absolute" : "fixed"
|
||||
)}
|
||||
onClick={() => setNarrowSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
|
||||
{!inlineApp && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
)
|
||||
)}
|
||||
style={{ width: `${calSidebarWidth}px` }}
|
||||
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
|
||||
>
|
||||
<MiniCalendar
|
||||
selectedDate={selectedDate}
|
||||
@@ -1309,17 +1358,20 @@ export default function CalendarPage() {
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||
/>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1341,6 +1393,7 @@ export default function CalendarPage() {
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -1413,7 +1466,7 @@ export default function CalendarPage() {
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<div className="shrink-0">
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft, Users } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ArrowLeft, Users, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -19,14 +21,17 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, generateUUID } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
@@ -43,6 +48,7 @@ type View =
|
||||
|
||||
export default function ContactsPage() {
|
||||
const t = useTranslations("contacts");
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
@@ -89,11 +95,26 @@ export default function ContactsPage() {
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||
const [returnToEmail, setReturnToEmail] = useState(false);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const isMobile = useIsMobile();
|
||||
const isDesktop = useIsDesktop();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// One-shot intent flag: only consume the URL params on the first render that
|
||||
// has them. After applying, we strip the query so a later refresh or
|
||||
// re-mount doesn't re-trigger the navigation.
|
||||
const intentAppliedRef = useRef(false);
|
||||
// Narrow pane (Pro split or small window): the categories sidebar collapses
|
||||
// into a burger-toggled overlay.
|
||||
const isNarrow = !isDesktop;
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
|
||||
// Panel resize state - sidebar (categories)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
@@ -130,12 +151,41 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Pro shell only: aggregate contacts and address books from every
|
||||
// connected account so the sidebar lists them all. The hook is a no-op
|
||||
// outside the embedded shell.
|
||||
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountContacts();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && supportsSync && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchContacts(client);
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
}, [client, supportsSync, fetchContacts, isEmbedded]);
|
||||
|
||||
// Consume one-shot URL params (set by the mobile recipient popover when no
|
||||
// sidebar is available) and strip them so a refresh doesn't replay the
|
||||
// intent. `from=email` flips the mobile back button to `router.back()`.
|
||||
useEffect(() => {
|
||||
if (intentAppliedRef.current) return;
|
||||
const contactId = searchParams.get('contactId');
|
||||
const addEmail = searchParams.get('addEmail');
|
||||
const addName = searchParams.get('addName');
|
||||
const from = searchParams.get('from');
|
||||
if (!contactId && !addEmail && !from) return;
|
||||
intentAppliedRef.current = true;
|
||||
if (from === 'email') setReturnToEmail(true);
|
||||
if (contactId) {
|
||||
setSelectedContact(contactId);
|
||||
setView('detail');
|
||||
} else if (addEmail) {
|
||||
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
|
||||
setSelectedContact(null);
|
||||
setView('create');
|
||||
}
|
||||
router.replace('/contacts');
|
||||
}, [searchParams, router, setSelectedContact]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh contacts via JMAP instead of reloading the page.
|
||||
@@ -143,6 +193,17 @@ export default function ContactsPage() {
|
||||
enabled: isAuthenticated && !!client && supportsSync,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
const activeId = useAuthStore.getState().activeAccountId;
|
||||
if (activeId) {
|
||||
const { fetchAllAccountsContacts, fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||
await Promise.all([
|
||||
fetchAllAccountsAddressBooks(accountClients, activeId),
|
||||
fetchAllAccountsContacts(accountClients, activeId),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await fetchContacts(client);
|
||||
},
|
||||
});
|
||||
@@ -194,6 +255,7 @@ export default function ContactsPage() {
|
||||
} else {
|
||||
setSelectedGroupId(null);
|
||||
}
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [clearSelection]);
|
||||
|
||||
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
|
||||
@@ -336,8 +398,14 @@ export default function ContactsPage() {
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setCreatePrefill(undefined);
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
|
||||
|
||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (!selectedContact) return;
|
||||
@@ -354,6 +422,14 @@ export default function ContactsPage() {
|
||||
|
||||
const handleCancel = () => {
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
// Came from email → cancel returns to the email instead of the contact list.
|
||||
if (returnToEmail && view === "create") {
|
||||
setCreatePrefill(undefined);
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (view === "create") setCreatePrefill(undefined);
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
@@ -525,7 +601,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -641,20 +717,38 @@ export default function ContactsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!contactsEnabled) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center bg-background p-6">
|
||||
<div className="max-w-lg text-center space-y-3">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
|
||||
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showListPanel = !isMobile || view === "list";
|
||||
const showRightPanel = !isMobile || view !== "list";
|
||||
|
||||
const mobileBackToList = () => {
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
setCreatePrefill(undefined);
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
|
||||
{/* Navigation Rail - desktop only */}
|
||||
{!isMobile && (
|
||||
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */}
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -673,18 +767,33 @@ export default function ContactsPage() {
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{/* Narrow-pane backdrop for the overlay categories sidebar */}
|
||||
{isNarrow && narrowSidebarOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"inset-0 bg-black/50 z-40",
|
||||
isEmbedded ? "absolute" : "fixed"
|
||||
)}
|
||||
onClick={() => setNarrowSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{showListPanel && (
|
||||
<>
|
||||
{/* Panel 1: Categories sidebar */}
|
||||
{!isMobile && (
|
||||
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
|
||||
{(!isMobile || isNarrow) && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border flex flex-col flex-shrink-0",
|
||||
!isSidebarResizing && "transition-[width] duration-300"
|
||||
"border-r border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!isSidebarResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
)
|
||||
)}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<ContactsSidebar
|
||||
groups={groups}
|
||||
@@ -721,17 +830,20 @@ export default function ContactsPage() {
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsSidebarResizing(false);
|
||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
{!isNarrow && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsSidebarResizing(false);
|
||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -764,6 +876,7 @@ export default function ContactsPage() {
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -793,7 +906,7 @@ export default function ContactsPage() {
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_contacts")}
|
||||
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -804,7 +917,7 @@ export default function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -16,6 +17,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -32,6 +34,9 @@ export default function FilesPage() {
|
||||
const t = useTranslations("files");
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
@@ -84,6 +89,7 @@ export default function FilesPage() {
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
@@ -128,13 +134,18 @@ export default function FilesPage() {
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Initialize JMAP files client
|
||||
// Initialize JMAP files client. In the Pro shell, all connected accounts
|
||||
// are surfaced as top-level folders at the root, so we *don't* auto-attach
|
||||
// to the active account - the user picks one explicitly.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
initClient(client);
|
||||
if (!isAuthenticated || !client || hasFetched.current) return;
|
||||
hasFetched.current = true;
|
||||
if (isEmbedded) {
|
||||
useFileStore.getState().clearClient();
|
||||
} else {
|
||||
initClient(client, activeAccountId);
|
||||
}
|
||||
}, [isAuthenticated, client, initClient]);
|
||||
}, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh files via JMAP instead of reloading the page.
|
||||
@@ -158,6 +169,17 @@ export default function FilesPage() {
|
||||
}, [storeClient, supportsFiles, checkSupport, navigate]);
|
||||
|
||||
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
|
||||
// Pro shell only: the Account breadcrumb segment signals "go to this
|
||||
// account's filesystem root" via a sentinel, distinguishing it from a
|
||||
// Home click (which detaches the account and returns to the picker).
|
||||
if (resourceId === '__account_root__') {
|
||||
void navigate(null);
|
||||
return;
|
||||
}
|
||||
if (isEmbedded && path === '/' && resourceId === undefined) {
|
||||
useFileStore.getState().clearClient();
|
||||
return;
|
||||
}
|
||||
if (resourceId !== undefined) {
|
||||
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
|
||||
navigate(resourceId, path.split('/').pop() || '');
|
||||
@@ -165,7 +187,7 @@ export default function FilesPage() {
|
||||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||||
navigateByPath(path);
|
||||
}
|
||||
}, [navigate, navigateByPath]);
|
||||
}, [navigate, navigateByPath, isEmbedded]);
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
@@ -372,13 +394,45 @@ export default function FilesPage() {
|
||||
setShowDetails(v => !v);
|
||||
}, []);
|
||||
|
||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
const accountFolders = isEmbedded
|
||||
? accounts
|
||||
.filter((a) => a.isConnected)
|
||||
.map((a) => ({
|
||||
accountId: a.id,
|
||||
label: a.label || a.email,
|
||||
email: a.email,
|
||||
avatarColor: a.avatarColor,
|
||||
}))
|
||||
: [];
|
||||
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
|
||||
const currentAccountLabel = isEmbedded && currentFilesAccountId
|
||||
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|
||||
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|
||||
|| null)
|
||||
: null;
|
||||
|
||||
const handleSelectAccount = useCallback((accountId: string) => {
|
||||
const nextClient = getClientForAccount(accountId);
|
||||
if (!nextClient) return;
|
||||
const store = useFileStore.getState();
|
||||
store.initClient(nextClient, accountId);
|
||||
// Reset supportsFiles so the existing checkSupport effect re-runs for
|
||||
// the freshly-attached client and triggers the initial navigate(null).
|
||||
useFileStore.setState({ supportsFiles: null });
|
||||
}, [getClientForAccount]);
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{!isMobile && (
|
||||
{!isMobile && !isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -399,7 +453,7 @@ export default function FilesPage() {
|
||||
)}
|
||||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{folderLayout !== "sidebar" && (
|
||||
{folderLayout !== "sidebar" && !isEmbedded && (
|
||||
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
@@ -477,6 +531,10 @@ export default function FilesPage() {
|
||||
showDetails={showDetails}
|
||||
onToggleDetails={handleToggleDetails}
|
||||
detailResource={detailResource}
|
||||
accountFolders={accountFolders}
|
||||
onSelectAccount={handleSelectAccount}
|
||||
accountPickerMode={isAccountPicker}
|
||||
accountLabel={currentAccountLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -484,7 +542,7 @@ export default function FilesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
{isMobile && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
@@ -6,6 +6,9 @@ import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-p
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -34,7 +37,10 @@ export default async function LocaleLayout({
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
<ProInterfaceRedirect />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
@@ -108,12 +108,29 @@ function VersionBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
// Only redirect targets matching this scheme are honored by the mobile
|
||||
// handoff path. Without the check the login page becomes an open redirector
|
||||
// that funnels password and token material to any caller-supplied URL.
|
||||
const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
|
||||
// When the mobile app launches the webmail in a browser tab it tacks on
|
||||
// these params. We grab them once at mount and stash them in a ref so any
|
||||
// login path that completes (password or OAuth) can hand control back to
|
||||
// the app instead of routing into /mail.
|
||||
const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? "";
|
||||
const rawMobileState = searchParams.get("mobile_state") ?? "";
|
||||
const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)
|
||||
? rawMobileRedirectUri
|
||||
: "";
|
||||
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
@@ -159,6 +176,9 @@ export default function LoginPage() {
|
||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevError = useRef<string | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||
// Captured by handleSubmit when in mobile handoff mode; consumed by the
|
||||
// isAuthenticated effect to build the deep-link fragment.
|
||||
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
initializeTheme();
|
||||
@@ -238,6 +258,19 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isAddAccountMode) {
|
||||
// Mobile handoff: the password path completes here once the auth store
|
||||
// flips isAuthenticated. Hand the verified credentials back to the
|
||||
// mobile app instead of pushing to /mail. handleSubmit captured the
|
||||
// values needed for the fragment.
|
||||
if (isMobileHandoff && mobileHandoffPayloadRef.current) {
|
||||
const fragment = new URLSearchParams({
|
||||
flow: "password",
|
||||
...mobileHandoffPayloadRef.current,
|
||||
state: mobileState,
|
||||
});
|
||||
window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`);
|
||||
return;
|
||||
}
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
@@ -248,7 +281,7 @@ export default function LoginPage() {
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, router, isAddAccountMode]);
|
||||
}, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
@@ -316,6 +349,16 @@ export default function LoginPage() {
|
||||
try {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
// In mobile-handoff mode the callback page needs to know it should
|
||||
// redirect into the app rather than into /mail. Stash the params in
|
||||
// sessionStorage so the same-tab callback can read them - the SSO
|
||||
// pending cookie carries the authoritative copy server-side too.
|
||||
if (isMobileHandoff) {
|
||||
try {
|
||||
sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri);
|
||||
sessionStorage.setItem("mobile_state", mobileState);
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
}
|
||||
const res = await apiFetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -324,6 +367,9 @@ export default function LoginPage() {
|
||||
redirect_uri: redirectUri,
|
||||
locale: params.locale,
|
||||
server_id: selectedServer?.id,
|
||||
...(isMobileHandoff
|
||||
? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -350,7 +396,7 @@ export default function LoginPage() {
|
||||
} catch {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
}, [params.locale, selectedServer?.id]);
|
||||
}, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||
@@ -492,6 +538,15 @@ export default function LoginPage() {
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||
// In mobile-handoff mode the client-side PKCE flow doesn't help us:
|
||||
// tokens would land in sessionStorage on the webmail origin and the
|
||||
// mobile app couldn't read them. Route through the server-side SSO
|
||||
// path instead, which has the mobile-aware /api/auth/sso/complete
|
||||
// branch.
|
||||
if (isMobileHandoff) {
|
||||
await startServerSideSso();
|
||||
return;
|
||||
}
|
||||
setOauthLoading(true);
|
||||
|
||||
const verifier = generateCodeVerifier();
|
||||
@@ -546,6 +601,16 @@ export default function LoginPage() {
|
||||
// when the admin hasn't configured a server list.
|
||||
const effectiveServerUrl = selectedServer?.url
|
||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||
// Capture before login() so the isAuthenticated effect can build the
|
||||
// deep-link fragment with values the user actually typed (formData may
|
||||
// be cleared by the auth store on success).
|
||||
if (isMobileHandoff) {
|
||||
mobileHandoffPayloadRef.current = {
|
||||
server_url: effectiveServerUrl,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
};
|
||||
}
|
||||
const success = await login(
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
@@ -556,7 +621,15 @@ export default function LoginPage() {
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
if (isMobileHandoff) {
|
||||
// The isAuthenticated effect handles the redirect; nothing else to
|
||||
// do here. Don't push to / - that would race the deep link.
|
||||
return;
|
||||
}
|
||||
router.push('/');
|
||||
} else if (isMobileHandoff) {
|
||||
// Stale payload should never feed into a later retry's redirect.
|
||||
mobileHandoffPayloadRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,6 +49,9 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
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 { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -67,6 +70,9 @@ import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
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() {
|
||||
@@ -79,6 +85,9 @@ export default function Home() {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
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 { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||
@@ -211,6 +220,7 @@ export default function Home() {
|
||||
|
||||
// Mobile/tablet responsive hooks
|
||||
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 {
|
||||
emails,
|
||||
@@ -272,8 +282,16 @@ export default function Home() {
|
||||
batchMarkAsRead,
|
||||
batchMarkAsSpam,
|
||||
batchUndoSpam,
|
||||
accountMailboxes,
|
||||
viewingAccountId,
|
||||
selectAccountMailbox,
|
||||
setViewingAccount,
|
||||
} = 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 accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
@@ -623,6 +641,65 @@ export default function Home() {
|
||||
document.title = title;
|
||||
}, [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
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
@@ -807,15 +884,17 @@ export default function Home() {
|
||||
// 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
|
||||
// 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(() => {
|
||||
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||
if (!enableUnifiedMailbox && !isEmbedded) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
const built = buildUnifiedAccounts();
|
||||
if (built.length < 2) return;
|
||||
populateUnifiedAccountMailboxes(built).then((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
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -1000,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) => {
|
||||
if (selectedEmail) {
|
||||
const ok = await emailHooks.onBeforeReply.intercept({
|
||||
@@ -1008,6 +1131,9 @@ export default function Home() {
|
||||
mode: 'reply' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
await prepareComposerQuoteHeader(selectedEmail, 'reply');
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
setComposerDraftText(draftText || "");
|
||||
setComposerMode('reply');
|
||||
@@ -1075,6 +1201,9 @@ export default function Home() {
|
||||
mode: 'reply-all' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
await prepareComposerQuoteHeader(selectedEmail, 'replyAll');
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
@@ -1089,6 +1218,9 @@ export default function Home() {
|
||||
mode: 'forward' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
await prepareComposerQuoteHeader(selectedEmail, 'forward');
|
||||
} else {
|
||||
setComposerQuoteHeader(null);
|
||||
}
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
@@ -1297,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) => {
|
||||
if (isUnifiedMailboxId(mailboxId)) {
|
||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||
@@ -1635,7 +1796,6 @@ export default function Home() {
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
if (isUnifiedView) return;
|
||||
setSearchQuery(query);
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
@@ -1647,14 +1807,25 @@ export default function Home() {
|
||||
const handleClearSearch = async () => {
|
||||
setSearchQuery("");
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = async () => {
|
||||
if (!client) return;
|
||||
if (isUnifiedView) return;
|
||||
await advancedSearch(client);
|
||||
};
|
||||
|
||||
@@ -1664,9 +1835,9 @@ export default function Home() {
|
||||
clearTimeout(advancedSearchDebounceRef.current);
|
||||
}
|
||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||
if (client && !isUnifiedView) advancedSearch(client);
|
||||
if (client) advancedSearch(client);
|
||||
}, 300);
|
||||
}, [client, advancedSearch, isUnifiedView]);
|
||||
}, [client, advancedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1802,7 +1973,7 @@ export default function Home() {
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
@@ -1917,22 +2088,25 @@ export default function Home() {
|
||||
};
|
||||
|
||||
// Handle reply from conversation view
|
||||
const handleConversationReply = (email: Email) => {
|
||||
const handleConversationReply = async (email: Email) => {
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'reply');
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleConversationReplyAll = (email: Email) => {
|
||||
const handleConversationReplyAll = async (email: Email) => {
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleConversationForward = (email: Email) => {
|
||||
const handleConversationForward = async (email: Email) => {
|
||||
selectEmail(email);
|
||||
await prepareComposerQuoteHeader(email, 'forward');
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -1960,7 +2134,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<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 && (
|
||||
<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">
|
||||
@@ -1976,8 +2150,8 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop Navigation Rail */}
|
||||
{!isMobile && !isTablet && (
|
||||
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell) */}
|
||||
{!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)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -1997,25 +2171,44 @@ export default function Home() {
|
||||
<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 && (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full z-50",
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
// 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",
|
||||
isEmbedded
|
||||
? (isMobile || isTablet
|
||||
? cn(
|
||||
"absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!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"
|
||||
)}
|
||||
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
|
||||
@@ -2047,6 +2240,10 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onSidebarClose={() => setSidebarOpen(false)}
|
||||
multiAccountMode={isEmbedded}
|
||||
accountMailboxes={accountMailboxes}
|
||||
viewingAccountId={viewingAccountId}
|
||||
onAccountMailboxSelect={handleAccountMailboxSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -2139,8 +2336,6 @@ export default function Home() {
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -2156,15 +2351,13 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "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" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -2323,6 +2516,14 @@ export default function Home() {
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
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}
|
||||
// Context menu handlers
|
||||
onReply={(email) => {
|
||||
@@ -2395,7 +2596,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
@@ -2428,13 +2629,16 @@ export default function Home() {
|
||||
shouldHideHorizontalViewerPane && "md:hidden"
|
||||
)}
|
||||
>
|
||||
{/* Inline Composer - shown in viewer pane */}
|
||||
{showComposer ? (
|
||||
{/* Inline Composer - shown in viewer pane.
|
||||
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
|
||||
fallback={ComposerErrorFallback}
|
||||
onReset={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
setComposerQuoteHeader(null);
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
@@ -2454,6 +2658,9 @@ export default function Home() {
|
||||
messageId: selectedEmail.messageId,
|
||||
inReplyTo: selectedEmail.inReplyTo,
|
||||
references: selectedEmail.references,
|
||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||
quoteHeaderText: composerQuoteHeader?.text,
|
||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
@@ -2473,6 +2680,7 @@ export default function Home() {
|
||||
setComposerMode('compose');
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerQuoteHeader(null);
|
||||
if (isMobile) {
|
||||
setActiveView('list');
|
||||
}
|
||||
@@ -2586,8 +2794,8 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation - mobile and tablet */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && (
|
||||
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
|
||||
{(isMobile || isTablet) && activeView !== "viewer" && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
@@ -160,6 +161,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
'settings.account.email',
|
||||
'settings.account.server',
|
||||
'settings.account.storage',
|
||||
'settings.account.accounts',
|
||||
],
|
||||
language: ['settings.appearance.language'],
|
||||
notifications: ['settings.notifications'],
|
||||
@@ -227,7 +229,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
// Extra English keywords per tab so common search terms hit even when the
|
||||
// translation doesn't contain the literal word.
|
||||
const tabKeywords: Record<Tab, string> = {
|
||||
account: 'profile email password user signin signout',
|
||||
account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account',
|
||||
language: 'locale region timezone date time format',
|
||||
notifications: 'sound alert push badge',
|
||||
appearance: 'theme dark light font size accent color animation density',
|
||||
@@ -346,6 +348,7 @@ export default function SettingsPage() {
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const { stalwartFeaturesEnabled } = useConfig();
|
||||
@@ -360,6 +363,7 @@ export default function SettingsPage() {
|
||||
const installedPlugins = usePluginStore((s) => s.plugins);
|
||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||
const sidebarAppsList = useSettingsStore((s) => s.sidebarApps);
|
||||
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||
|
||||
// Build a per-tab haystack for fulltext search and a list of sub-results
|
||||
// (individual settings) per tab. Sub-results come from translation entries
|
||||
@@ -590,7 +594,7 @@ export default function SettingsPage() {
|
||||
|
||||
// Apps
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
|
||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
|
||||
@@ -687,7 +691,7 @@ export default function SettingsPage() {
|
||||
if (!isDesktop) {
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
@@ -705,20 +709,22 @@ export default function SettingsPage() {
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<Button
|
||||
@@ -815,13 +821,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
{!isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
</div>
|
||||
);
|
||||
@@ -829,21 +837,23 @@ export default function SettingsPage() {
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<div className="flex flex-col h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
{!isEmbedded && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
activeAppId={inlineApp?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
|
||||
@@ -857,17 +867,19 @@ export default function SettingsPage() {
|
||||
)}
|
||||
style={{ width: `${settingsSidebarWidth}px` }}
|
||||
>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
{!proInterface && (
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||
<div className="px-3 pt-1 pb-1">
|
||||
@@ -6,7 +6,7 @@ import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
|
||||
// `value` omitted and `hasValue` set instead — the server never echoes
|
||||
// `value` omitted and `hasValue` set instead - the server never echoes
|
||||
// the raw secret to the client.
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
@@ -271,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 Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved — type to replace)' : undefined} />
|
||||
<Text label="OAuth 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 Scopes" description="Space-separated scopes that replace the defaults. Leave blank to use the built-in scope list." configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="openid email offline_access" />
|
||||
<Text label="OAuth Extra Scopes" description="Additional space-separated scopes appended to the defaults." configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
|
||||
</Section>
|
||||
|
||||
<Section title="Single Sign-On">
|
||||
@@ -25,6 +25,20 @@ const TEXT_FIELDS = [
|
||||
{ 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() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
@@ -262,6 +276,142 @@ export function BrandingTab() {
|
||||
</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="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle } 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 { isVersionSatisfied } from '@/lib/version-compare';
|
||||
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Extension {
|
||||
minAppVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
author: {
|
||||
@@ -104,6 +105,8 @@ export function MarketplaceTab() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isUpdate = ext.installed;
|
||||
const targetVersion = ext.latestVersion || '1.0.0';
|
||||
setInstalling(ext.slug);
|
||||
setMessage(null);
|
||||
|
||||
@@ -113,7 +116,7 @@ export function MarketplaceTab() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: ext.slug,
|
||||
version: ext.latestVersion || '1.0.0',
|
||||
version: targetVersion,
|
||||
type: ext.type,
|
||||
}),
|
||||
});
|
||||
@@ -122,13 +125,22 @@ export function MarketplaceTab() {
|
||||
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
|
||||
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${ext.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${ext.name}" installed successfully${warnings}`,
|
||||
});
|
||||
setExtensions(prev => prev.map(e =>
|
||||
e.slug === ext.slug
|
||||
? { ...e, installed: true, installedVersion: targetVersion }
|
||||
: e,
|
||||
));
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||
setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
@@ -270,6 +282,11 @@ function ExtensionCard({
|
||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||
const versionMismatch = !!extension.minAppVersion
|
||||
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||
const updateAvailable = extension.installed
|
||||
&& !!extension.installedVersion
|
||||
&& !!extension.latestVersion
|
||||
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||
@@ -359,8 +376,25 @@ function ExtensionCard({
|
||||
</Link>
|
||||
|
||||
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||
{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">
|
||||
{extension.installed && updateAvailable ? (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||
disabled={installing}
|
||||
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{installing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
)}
|
||||
Update to v{extension.latestVersion}
|
||||
</button>
|
||||
) : extension.installed ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
|
||||
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
Installed
|
||||
</span>
|
||||
+23
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
|
||||
interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
@@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginAdminSection pluginId={pluginId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
|
||||
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
|
||||
* and the host gives it a dedicated iframe inside the admin panel.
|
||||
*/
|
||||
function PluginAdminSection({ pluginId }: { pluginId: string }) {
|
||||
const offers = usePluginSlotOffers('admin-plugin-page');
|
||||
const offer = offers.find((o) => o.pluginId === pluginId);
|
||||
if (!offer) return null;
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Plugin admin panel
|
||||
</div>
|
||||
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+44
-8
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowUpCircle,
|
||||
Download,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { isVersionSatisfied } from '@/lib/version-compare';
|
||||
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
|
||||
|
||||
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
|
||||
@@ -73,6 +74,7 @@ interface PreviewData {
|
||||
error: string | null;
|
||||
};
|
||||
installed: boolean;
|
||||
installedVersion: string | null;
|
||||
}
|
||||
|
||||
const RISKY_PERMISSIONS = new Set([
|
||||
@@ -118,6 +120,8 @@ export default function MarketplacePreviewPage() {
|
||||
|
||||
async function handleInstall() {
|
||||
if (!data) return;
|
||||
const isUpdate = data.installed;
|
||||
const targetVersion = data.extension.latestVersion || '1.0.0';
|
||||
setInstalling(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
@@ -126,20 +130,25 @@ export default function MarketplacePreviewPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: data.extension.slug,
|
||||
version: data.extension.latestVersion || '1.0.0',
|
||||
version: targetVersion,
|
||||
type: data.extension.type,
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
|
||||
setData(prev => prev ? { ...prev, installed: true } : prev);
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: isUpdate
|
||||
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
|
||||
: `"${data.extension.name}" installed${warnings}`,
|
||||
});
|
||||
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: body.error || 'Installation failed' });
|
||||
setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
@@ -204,6 +213,11 @@ export default function MarketplacePreviewPage() {
|
||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||
const updateAvailable = data.installed
|
||||
&& !!data.installedVersion
|
||||
&& !!ext.latestVersion
|
||||
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
|
||||
&& !versionMismatch;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
@@ -248,11 +262,22 @@ export default function MarketplacePreviewPage() {
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
|
||||
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
|
||||
{data.installed && (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium">
|
||||
{data.installed && !updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
|
||||
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
|
||||
>
|
||||
<Check className="w-3 h-3" /> Installed
|
||||
</span>
|
||||
)}
|
||||
{data.installed && updateAvailable && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
|
||||
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
|
||||
>
|
||||
<ArrowUpCircle className="w-3 h-3" /> Update available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
@@ -279,6 +304,17 @@ export default function MarketplacePreviewPage() {
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{data.installed ? (
|
||||
<>
|
||||
{updateAvailable && (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !!bundle.error}
|
||||
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
|
||||
Update to v{ext.latestVersion}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
@@ -5,7 +5,7 @@ import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import "./globals.css";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -101,20 +101,17 @@ export default function SetupWizardPage() {
|
||||
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
// Detect synchronously on first client render so we don't flash the loading
|
||||
// screen before the warning appears. The session cookie is set with the
|
||||
// Secure flag in production, which browsers silently drop over plain HTTP -
|
||||
// every subsequent step call then 401s with "Wizard session required".
|
||||
const [insecureContext] = useState<boolean>(detectInsecureContext);
|
||||
// Resolved in a post-mount effect, not at render, so the server-rendered
|
||||
// HTML (where window is absent) matches the client's first paint and
|
||||
// doesn't trip a hydration mismatch.
|
||||
const [insecureContext, setInsecureContext] = useState(false);
|
||||
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
|
||||
useEffect(() => {
|
||||
setInsecureContext(detectInsecureContext());
|
||||
}, []);
|
||||
|
||||
// ─── Initial status load ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
// Skip the status fetch entirely when we're going to render the HTTPS
|
||||
// notice - the wizard cookie can't survive an HTTP origin anyway.
|
||||
if (insecureContext) {
|
||||
setBootstrapping(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -152,7 +149,7 @@ export default function SetupWizardPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router, insecureContext]);
|
||||
}, [router]);
|
||||
|
||||
// ─── Token submit (welcome step) ────────────────────────────────────────
|
||||
async function submitToken(token: string) {
|
||||
@@ -184,8 +181,8 @@ export default function SetupWizardPage() {
|
||||
}
|
||||
|
||||
// ─── Render shell ───────────────────────────────────────────────────────
|
||||
if (insecureContext) {
|
||||
return <InsecureContextScreen />;
|
||||
if (insecureContext && !insecureAcknowledged) {
|
||||
return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
|
||||
}
|
||||
|
||||
if (bootstrapping) {
|
||||
@@ -362,7 +359,7 @@ function CompletedScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function InsecureContextScreen() {
|
||||
function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
|
||||
const httpsUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `https://${window.location.host}${window.location.pathname}${window.location.search}`
|
||||
@@ -373,29 +370,29 @@ function InsecureContextScreen() {
|
||||
<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">HTTPS required for setup</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie,
|
||||
which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every
|
||||
step to fail with <em>Wizard session required</em>.
|
||||
<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-5 text-left text-sm text-muted-foreground space-y-2">
|
||||
<p className="font-medium text-foreground">To continue, do one of the following:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).</li>
|
||||
<li>If you already have a reverse proxy, make sure it forwards to the webmail and forwards the
|
||||
<code className="font-mono text-xs"> X-Forwarded-Proto</code> header.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{httpsUrl && (
|
||||
<a
|
||||
href={httpsUrl}
|
||||
className="mt-6 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"
|
||||
<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"
|
||||
>
|
||||
Open over HTTPS
|
||||
</a>
|
||||
)}
|
||||
Continue over HTTP
|
||||
</button>
|
||||
</div>
|
||||
</CenteredCard>
|
||||
);
|
||||
}
|
||||
@@ -743,6 +740,21 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isPrivateOrLocalHostUrl(config.jmapServerUrl) && (
|
||||
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm font-medium text-foreground leading-relaxed">
|
||||
This URL only resolves locally.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
|
||||
Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. <code className="font-mono text-xs">https://mail.example.com</code>) in production.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{probe && probe.url === config.jmapServerUrl && (
|
||||
probe.status === 'jmap_detected' ? (
|
||||
<div className="mt-2 p-3 rounded-xl border border-success/20 bg-success/5 flex items-start gap-3">
|
||||
@@ -1782,13 +1794,52 @@ function isInsecureHttpUrl(url: string): boolean {
|
||||
return /^http:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* The JMAP URL is called directly from the user's browser. A URL that only
|
||||
* resolves on the operator's machine or LAN (localhost, RFC1918, .local mDNS)
|
||||
* works during setup but breaks for any real user. Surface a soft warning
|
||||
* so the operator catches this before going live.
|
||||
*/
|
||||
function isPrivateOrLocalHostUrl(url: string): boolean {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return false;
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(trimmed).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Strip IPv6 brackets, if any.
|
||||
if (host.startsWith('[') && host.endsWith(']')) {
|
||||
host = host.slice(1, -1);
|
||||
}
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return true;
|
||||
if (host.endsWith('.local')) return true;
|
||||
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;
|
||||
// IPv4 literal: only flag the well-known private/loopback/link-local ranges.
|
||||
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
||||
if (a === 10) return true;
|
||||
if (a === 127) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectInsecureContext(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (window.location.protocol !== 'http:') return false;
|
||||
// Browsers treat localhost/loopback as "potentially trustworthy" and accept
|
||||
// Secure cookies even without TLS, so the wizard still works there.
|
||||
// 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;
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
|
||||
const isLoopback =
|
||||
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||
if (isLoopback && process.env.NODE_ENV !== 'development') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -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) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10));
|
||||
|
||||
Binary file not shown.
@@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
/** Slots that correspond to branding config keys */
|
||||
const VALID_SLOTS = new Set([
|
||||
'faviconUrl',
|
||||
'pwaIconUrl',
|
||||
'appLogoLightUrl',
|
||||
'appLogoDarkUrl',
|
||||
'loginLogoLightUrl',
|
||||
@@ -44,7 +45,7 @@ function sanitizeFilename(name: string): string {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -114,7 +115,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Strings that count as "no real secret configured" — used so the dashboard
|
||||
// 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']);
|
||||
@@ -20,9 +20,9 @@ const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
||||
* the server so that an XSS or session-theft can't lift them in one
|
||||
* request and forge admin/user session cookies offline.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
@@ -54,7 +54,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -109,7 +109,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -7,8 +7,12 @@ import {
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
}
|
||||
|
||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
|
||||
@@ -19,17 +23,18 @@ const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||
* Lets admins audit what they're about to install before pressing the button.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { slug } = await params;
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
|
||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
|
||||
const detailRes = await fetch(detailUrl.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
@@ -63,7 +68,7 @@ export async function GET(
|
||||
try {
|
||||
const bundleUrl = new URL(
|
||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||
DIRECTORY_URL,
|
||||
directoryUrl,
|
||||
);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
@@ -144,14 +149,16 @@ export async function GET(
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
const installed = type === 'theme'
|
||||
? themeRegistry.themes.some((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.some((p) => p.id === slug);
|
||||
const installedEntry = type === 'theme'
|
||||
? themeRegistry.themes.find((t) => t.id === slug)
|
||||
: pluginRegistry.plugins.find((p) => p.id === slug);
|
||||
const installed = installedEntry !== undefined;
|
||||
const installedVersion = installedEntry?.version ?? null;
|
||||
|
||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||
const screenshots = Array.isArray(extension.screenshots)
|
||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
||||
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||
altText: s.altText ?? null,
|
||||
}))
|
||||
: [];
|
||||
@@ -170,7 +177,7 @@ export async function GET(
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
return NextResponse.json(
|
||||
@@ -206,6 +213,7 @@ export async function GET(
|
||||
error: bundleError,
|
||||
},
|
||||
installed,
|
||||
installedVersion,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
|
||||
import {
|
||||
savePlugin,
|
||||
saveTheme,
|
||||
getPlugin,
|
||||
getTheme,
|
||||
getPluginRegistry,
|
||||
getThemeRegistry,
|
||||
type ServerPlugin,
|
||||
@@ -13,13 +15,18 @@ import {
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
||||
async function getDirectoryUrl(): Promise<string> {
|
||||
await configManager.ensureLoaded();
|
||||
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
@@ -27,11 +34,12 @@ const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const { searchParams } = request.nextUrl;
|
||||
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
||||
const url = new URL('/api/v1/extensions', directoryUrl);
|
||||
|
||||
// Forward all search params
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
@@ -58,23 +66,32 @@ export async function GET(request: NextRequest) {
|
||||
getThemeRegistry(),
|
||||
]);
|
||||
|
||||
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
|
||||
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
|
||||
const installedPluginVersions = new Map(
|
||||
pluginRegistry.plugins.map(p => [p.id, p.version] as const),
|
||||
);
|
||||
const installedThemeVersions = new Map(
|
||||
themeRegistry.themes.map(t => [t.id, t.version] as const),
|
||||
);
|
||||
|
||||
const fileUrl = (path: unknown): string | null =>
|
||||
typeof path === 'string' && path
|
||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
||||
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||
: null;
|
||||
|
||||
if (data.data) {
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => ({
|
||||
...ext,
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: ext.type === 'theme'
|
||||
? installedThemes.has(ext.slug as string)
|
||||
: installedPlugins.has(ext.slug as string),
|
||||
}));
|
||||
data.data = data.data.map((ext: Record<string, unknown>) => {
|
||||
const slug = ext.slug as string;
|
||||
const installedVersion = ext.type === 'theme'
|
||||
? installedThemeVersions.get(slug) ?? null
|
||||
: installedPluginVersions.get(slug) ?? null;
|
||||
return {
|
||||
...ext,
|
||||
iconUrl: fileUrl(ext.iconPath),
|
||||
bannerUrl: fileUrl(ext.bannerPath),
|
||||
installed: installedVersion !== null,
|
||||
installedVersion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data, {
|
||||
@@ -92,7 +109,7 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -107,7 +124,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Download the bundle from the directory
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
||||
const directoryUrl = await getDirectoryUrl();
|
||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
@@ -163,6 +181,18 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Resolve and strictly validate the id used as a filename. Marketplace
|
||||
// bundles are authored by a third-party publisher; without this an id
|
||||
// like "../../foo" causes savePlugin/saveTheme to write outside the
|
||||
// plugins/themes dir via path.join.
|
||||
const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
|
||||
if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'theme') {
|
||||
// Read theme.css
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
@@ -181,22 +211,43 @@ export async function POST(request: NextRequest) {
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
|
||||
const existingTheme = await getTheme(resolvedId);
|
||||
const isUpdate = existingTheme !== null;
|
||||
|
||||
const theme: ServerTheme = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
// Prefer the directory-published version (what we requested) over
|
||||
// manifest.version. Publishers sometimes forget to bump the version
|
||||
// inside the bundle's manifest.json; trusting it would make the
|
||||
// update never appear to "stick" — the registry would keep showing
|
||||
// the older version even after a successful update.
|
||||
version: version || (manifest.version as string),
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
variants: (manifest.variants as string[]) || ['light', 'dark'],
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
enabled: existingTheme?.enabled ?? true,
|
||||
...(existingTheme?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingTheme.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingTheme?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await saveTheme(theme, css);
|
||||
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip);
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
|
||||
{
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
version: theme.version,
|
||||
slug,
|
||||
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, theme, warnings });
|
||||
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
|
||||
} else {
|
||||
// Plugin installation
|
||||
// Read entrypoint JS
|
||||
@@ -266,17 +317,37 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
|
||||
? (manifest.apiPostPaths as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedApiPostPaths.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingPlugin = await getPlugin(resolvedId);
|
||||
const isUpdate = existingPlugin !== null;
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
id: resolvedId,
|
||||
name: (manifest.name as string) || slug,
|
||||
version: (manifest.version as string) || version,
|
||||
// See theme branch: trust the directory-published version, not
|
||||
// manifest.version, so updates actually stick in the registry.
|
||||
version: version || (manifest.version as string),
|
||||
author: (manifest.author as string) || 'Unknown',
|
||||
description: (manifest.description as string) || '',
|
||||
type: (manifest.type as string) || 'hook',
|
||||
permissions,
|
||||
entrypoint,
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
enabled: existingPlugin?.enabled ?? true,
|
||||
...(existingPlugin?.forceEnabled !== undefined
|
||||
? { forceEnabled: existingPlugin.forceEnabled }
|
||||
: {}),
|
||||
installedAt: existingPlugin?.installedAt ?? now,
|
||||
updatedAt: now,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
@@ -290,13 +361,29 @@ export async function POST(request: NextRequest) {
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
await auditLog(
|
||||
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
|
||||
{
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
slug,
|
||||
frameOrigins: declaredFrameOrigins,
|
||||
httpOrigins: declaredHttpOrigins,
|
||||
apiPostPaths: declaredApiPostPaths,
|
||||
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
|
||||
},
|
||||
ip,
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
|
||||
@@ -84,7 +84,7 @@ function isValidOriginUrl(value: string): boolean {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
|
||||
*
|
||||
* GET /api/admin/plugin-approvals → list all entries
|
||||
* POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' }
|
||||
* DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke
|
||||
*/
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const entries = await listApprovals();
|
||||
return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
if (b.decision !== 'approved' && b.decision !== 'denied') {
|
||||
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
|
||||
}
|
||||
|
||||
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
|
||||
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
|
||||
return NextResponse.json({ entry });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
// AdminSessionPayload carries only role/iat/exp; we use a stable label
|
||||
// for the audit trail rather than a per-user identity.
|
||||
const adminUser = 'admin';
|
||||
void result;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
await revokeApproval(pluginId, bundleHash);
|
||||
await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
|
||||
import { signBytes } from '@/lib/admin/plugin-signing';
|
||||
|
||||
async function safeSign(code: string): Promise<string | null> {
|
||||
try { return await signBytes(code); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
@@ -25,14 +30,15 @@ export async function GET(
|
||||
const devEntry = await getDevPlugin(id);
|
||||
if (devEntry) {
|
||||
const code = await readDevBundle(devEntry);
|
||||
return new NextResponse(code, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
},
|
||||
});
|
||||
const signature = await safeSign(code);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'ETag': `"${devEntry.plugin.bundleHash}"`,
|
||||
'Content-Length': String(Buffer.byteLength(code, 'utf-8')),
|
||||
};
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
return new NextResponse(code, { headers });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
@@ -59,6 +65,9 @@ export async function GET(
|
||||
};
|
||||
if (etag) headers['ETag'] = etag;
|
||||
|
||||
const signature = await safeSign(code);
|
||||
if (signature) headers['X-Bundle-Signature'] = signature;
|
||||
|
||||
if (etag && request.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, { status: 304, headers });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminAuth = await requireAdminAuth();
|
||||
const adminAuth = await requireAdminAuth(request);
|
||||
const isAdmin = !('error' in adminAuth);
|
||||
|
||||
if (!isAdmin) {
|
||||
@@ -51,13 +51,18 @@ export async function GET(
|
||||
|
||||
const config = await getPluginConfig(id);
|
||||
|
||||
let response: Record<string, unknown> = config;
|
||||
if (!isAdmin && plugin.configSchema) {
|
||||
let response: Record<string, unknown>;
|
||||
if (isAdmin) {
|
||||
response = config;
|
||||
} else {
|
||||
response = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = plugin.configSchema[key];
|
||||
if (field?.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
const schema = plugin.configSchema;
|
||||
if (schema) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const field = schema[key];
|
||||
if (!field || field.type === 'secret') continue;
|
||||
response[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +85,7 @@ export async function PUT(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
@@ -110,6 +115,13 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (plugin.configSchema && !plugin.configSchema[body.key]) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Key is not declared in the plugin configSchema' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await setPluginConfig(id, body.key, body.value);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
@@ -127,7 +139,7 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
sanitizeHttpOrigins,
|
||||
sanitizeApiPostPaths,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
@@ -31,9 +32,9 @@ const SUSPICIOUS_JS_PATTERNS = [
|
||||
/**
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const [registry, devEntries] = await Promise.all([
|
||||
@@ -63,7 +64,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -172,6 +173,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||
const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
@@ -196,13 +198,16 @@ export async function POST(request: NextRequest) {
|
||||
...(declaredHttpOrigins.length > 0
|
||||
? { httpOrigins: declaredHttpOrigins }
|
||||
: {}),
|
||||
...(declaredApiPostPaths.length > 0
|
||||
? { apiPostPaths: declaredApiPostPaths }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
@@ -217,7 +222,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -235,9 +240,18 @@ export async function PATCH(request: NextRequest) {
|
||||
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||
|
||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||
const updated = await updatePluginMeta(id, updates);
|
||||
let updated = await updatePluginMeta(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
// Dev plugins (PLUGIN_DEV_DIR) aren't in the persisted registry, but
|
||||
// forceEnabled is canonical-stored in policy.forceEnabledPlugins on the
|
||||
// client. Skip the registry write and return the live dev plugin so the
|
||||
// policy save path can proceed.
|
||||
const devEntries = await listDevPlugins();
|
||||
const devEntry = devEntries.find(e => e.plugin.id === id);
|
||||
if (!devEntry) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
updated = { ...devEntry.plugin, ...updates };
|
||||
}
|
||||
|
||||
// Enable/disable changes the set of plugins contributing frame origins.
|
||||
@@ -259,7 +273,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
* Returns current consent + endpoint + next/last send + a live preview
|
||||
* of exactly what the next heartbeat would contain.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
@@ -61,7 +61,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
/**
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const registry = await getThemeRegistry();
|
||||
@@ -36,7 +36,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
@@ -193,7 +193,7 @@ export async function PATCH(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth();
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
* GET /api/admin/version
|
||||
* Returns the cached update status, last check times, and effective config.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const state = await loadState();
|
||||
@@ -47,7 +47,7 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth();
|
||||
const auth = await requireAdminAuth(req);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { action?: string } | null;
|
||||
|
||||
@@ -23,7 +23,7 @@ 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
|
||||
* 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.
|
||||
@@ -48,7 +48,7 @@ function impersonationCookieOptions() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readImpersonationConfig();
|
||||
if (!config) {
|
||||
// Not configured — behave exactly like an unknown route.
|
||||
// Not configured - behave exactly like an unknown route.
|
||||
return new NextResponse('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function GET(request: NextRequest) {
|
||||
authHeader,
|
||||
});
|
||||
|
||||
// Structured audit log — operators rely on this for security review.
|
||||
// Structured audit log - operators rely on this for security review.
|
||||
logger.info('Impersonation session granted', {
|
||||
event: 'impersonation_granted',
|
||||
jti: claims.jti,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import {
|
||||
exchangeCodeForTokens,
|
||||
getRequiredConfig,
|
||||
getTokenEndpoint,
|
||||
} from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
@@ -56,6 +60,10 @@ export async function POST(request: NextRequest) {
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
const mobileRedirectUri =
|
||||
typeof pending.mobile_redirect_uri === 'string' ? pending.mobile_redirect_uri : null;
|
||||
const mobileState = typeof pending.mobile_state === 'string' ? pending.mobile_state : null;
|
||||
const isMobileFlow = Boolean(mobileRedirectUri);
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
@@ -65,21 +73,46 @@ export async function POST(request: NextRequest) {
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
// For the mobile handoff flow the tokens are handed back to the app
|
||||
// verbatim - we deliberately don't write any cookies on the webmail
|
||||
// origin (the mobile browser tab disposes of the session after the
|
||||
// redirect anyway, but the cookie would still get committed to the
|
||||
// user's main webmail session if they happened to be logged in there).
|
||||
if (!isMobileFlow) {
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
if (isMobileFlow) {
|
||||
// The mobile client needs the bits it can't re-derive: the refresh
|
||||
// token, the token endpoint it should hit to refresh later, and the
|
||||
// client_id the IdP expects on that refresh call. The server URL is
|
||||
// returned so the app knows which JMAP host to connect to.
|
||||
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
|
||||
const tokenEndpoint = await getTokenEndpoint(pendingServerId);
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_endpoint: tokenEndpoint,
|
||||
client_id: clientId,
|
||||
server_url: serverUrl,
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
mobile_state: mobileState,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { getOauthScopes } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
@@ -12,18 +13,40 @@ import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
// The mobile app's deep-link scheme. Only redirect targets starting with
|
||||
// this prefix may flow through the mobile handoff path; without the guard
|
||||
// the SSO complete route would be coerced into returning tokens to whatever
|
||||
// caller-controlled URL the attacker chose.
|
||||
const MOBILE_REDIRECT_SCHEME = 'bulwarkmobile://';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!hasSessionSecret()) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
|
||||
const {
|
||||
redirect_uri,
|
||||
locale,
|
||||
server_id: bodyServerId,
|
||||
mobile_redirect_uri: rawMobileRedirectUri,
|
||||
mobile_state: rawMobileState,
|
||||
} = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const mobileRedirectUri =
|
||||
typeof rawMobileRedirectUri === 'string' && rawMobileRedirectUri
|
||||
? rawMobileRedirectUri
|
||||
: null;
|
||||
const mobileState =
|
||||
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||
if (mobileRedirectUri && !mobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
@@ -39,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
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
|
||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||
// Mobile params are captured here so /complete knows to return tokens to
|
||||
// the caller (in the JSON response) instead of writing the usual server
|
||||
// cookies - and so the callback page can redirect back to the app.
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
...(mobileRedirectUri ? { mobile_redirect_uri: mobileRedirectUri } : {}),
|
||||
...(mobileState ? { mobile_state: mobileState } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { JmapAuthVerificationError, assertBasicAuthMatchesUsername, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
@@ -57,15 +57,22 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const slot = getSlot(request, bodySlot);
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch: the caller
|
||||
// just authenticated to JMAP with these credentials, and the cookie we
|
||||
// write here is only ever consumed for requests on behalf of this same
|
||||
// user - a bogus auth header would just yield 401s downstream, not
|
||||
// privilege escalation. For untrusted custom endpoints we still verify
|
||||
// upstream as before.
|
||||
const normalizedServerUrl = upstreamTrusted
|
||||
? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl))
|
||||
: await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
// Trusted (admin-configured) URLs skip the upstream re-fetch, but we
|
||||
// still bind the cookie's `username` to the credential when we can verify
|
||||
// locally. Without this, a caller can POST username="admin@host" +
|
||||
// authHeader=<their own Basic creds>, and downstream consumers that read
|
||||
// the cookie-derived username (audit logs, login tracker) accept the
|
||||
// spoof. Bearer tokens are opaque so only the format check runs;
|
||||
// authorization sinks must key off the credential itself, not the
|
||||
// cookie's username claim (see admin/auth's authHeader-hashed cache key).
|
||||
let normalizedServerUrl: string;
|
||||
if (upstreamTrusted) {
|
||||
validateProxyAuthHeader(authHeader);
|
||||
assertBasicAuthMatchesUsername(authHeader, username);
|
||||
normalizedServerUrl = normalizeJmapServerUrl(upstreamUrl);
|
||||
} else {
|
||||
normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false });
|
||||
}
|
||||
|
||||
await setStalwartAuthContext(slot, {
|
||||
serverUrl: normalizedServerUrl,
|
||||
|
||||
@@ -54,7 +54,7 @@ async function tryTokenRequest(
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl);
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { decryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
|
||||
*
|
||||
* Any logged-in user may query the server-side approval state for a plugin
|
||||
* they want to enable. The client uses this BEFORE running `enablePlugin`
|
||||
* when the `requirePluginApproval` policy is set.
|
||||
*
|
||||
* POST same path with body `{ pluginId, bundleHash, manifest }` creates a
|
||||
* pending approval entry (or returns the existing one).
|
||||
*/
|
||||
|
||||
async function resolveUsername(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (token) {
|
||||
const sess = decryptSession(token);
|
||||
if (sess?.username) return sess.username;
|
||||
}
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (ctx?.username) return ctx.username;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidId(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64;
|
||||
}
|
||||
function isValidHash(s: unknown): s is string {
|
||||
return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
const pluginId = request.nextUrl.searchParams.get('pluginId');
|
||||
const bundleHash = request.nextUrl.searchParams.get('bundleHash');
|
||||
if (!isValidId(pluginId) || !isValidHash(bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
const status = await getApprovalStatus(pluginId, bundleHash);
|
||||
return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const username = await resolveUsername();
|
||||
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
|
||||
|
||||
let body: unknown;
|
||||
try { body = await request.json(); } catch { body = null; }
|
||||
const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
|
||||
if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
|
||||
return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
|
||||
}
|
||||
|
||||
const m = (b.manifest ?? {}) as Record<string, unknown>;
|
||||
const manifest: ApprovalEntry['manifest'] = {
|
||||
name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
|
||||
version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
|
||||
author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
|
||||
description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
|
||||
permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
|
||||
httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
|
||||
};
|
||||
|
||||
const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
|
||||
return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
|
||||
} catch (err) {
|
||||
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugin-signing-pubkey
|
||||
*
|
||||
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
|
||||
* sandboxed plugin loader can verify bundle signatures before evaluation.
|
||||
* Public - every logged-in user needs to fetch it on app boot.
|
||||
*
|
||||
* The response is long-cache-eligible (the key rotates only when an operator
|
||||
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
|
||||
* client caches the result in memory for the lifetime of the page.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const publicKey = await getPublicKeyBase64();
|
||||
return NextResponse.json(
|
||||
{ algorithm: 'ed25519', publicKey },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,10 @@ import { logger } from '@/lib/logger';
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const policyForceEnabledIds = new Set(policy.forceEnabledPlugins || []);
|
||||
|
||||
const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([
|
||||
getPluginRegistry(),
|
||||
getThemeRegistry(),
|
||||
@@ -34,7 +39,11 @@ export async function GET() {
|
||||
type: p.type,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
forceEnabled: p.forceEnabled || false,
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
// can drift for dev plugins (manifest always loads forceEnabled:false)
|
||||
// and during pending policy saves; OR'ing here unifies the signal so
|
||||
// the client's auto-enable path triggers consistently.
|
||||
forceEnabled: p.forceEnabled || policyForceEnabledIds.has(p.id),
|
||||
// Content hash + updatedAt let clients detect re-uploads even when
|
||||
// the manifest version is unchanged.
|
||||
bundleHash: p.bundleHash,
|
||||
@@ -43,6 +52,8 @@ export async function GET() {
|
||||
dev: p.dev,
|
||||
// Surface so clients can enforce api.http.fetch origin allowlists.
|
||||
httpOrigins: p.httpOrigins,
|
||||
// Surface so clients can enforce api.http.post path allowlists.
|
||||
apiPostPaths: p.apiPostPaths,
|
||||
// Per-user settings schema, captured from the manifest at upload/load
|
||||
// time so the client can render the settings UI without re-parsing.
|
||||
settingsSchema: p.settingsSchema,
|
||||
|
||||
@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
// Cache resized images keyed by (size, source URL) so admin re-uploads or URL
|
||||
// changes invalidate the prior render instead of serving stale bytes forever.
|
||||
const cache = new Map<string, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
@@ -16,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Admin-uploaded branding asset: served from /api/admin/branding/<file>
|
||||
// but stored on disk under getConfigDir()/branding/.
|
||||
const ADMIN_BRANDING_PREFIX = '/api/admin/branding/';
|
||||
if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) {
|
||||
const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length));
|
||||
return readFile(path.join(getConfigDir(), 'branding', filename));
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
@@ -32,7 +43,11 @@ export async function GET(
|
||||
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||
}
|
||||
|
||||
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||
await configManager.ensureLoaded();
|
||||
const 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) {
|
||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||
}
|
||||
@@ -42,9 +57,11 @@ export async function GET(
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
};
|
||||
|
||||
const cacheKey = `${size}|${iconUrl}`;
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
if (cache.has(cacheKey)) {
|
||||
return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
@@ -56,7 +73,7 @@ export async function GET(
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
cache.set(cacheKey, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 1. Provision the admin account. An admin.json file may already exist
|
||||
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
|
||||
// run while setupComplete is still false — accept the wizard's
|
||||
// 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 });
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const attrs = buildSessionCookieAttributes();
|
||||
const attrs = buildSessionCookieAttributes(request);
|
||||
response.cookies.set(attrs.name, submitted, {
|
||||
httpOnly: attrs.httpOnly,
|
||||
sameSite: attrs.sameSite,
|
||||
|
||||
@@ -240,6 +240,13 @@ body {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.email-content-text {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.email-content-text a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
|
||||
+16
-9
@@ -1,4 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & {
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
|
||||
export default function manifest(): ExtendedManifest {
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
configManager.get<string>("appName") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||
const shortName = configManager.get<string>("appShortName") || appName;
|
||||
const description =
|
||||
process.env.APP_DESCRIPTION ||
|
||||
configManager.get<string>("appDescription") ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
||||
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
||||
|
||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
||||
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
||||
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
|
||||
// are the built-in placeholder paths and not real custom icons.
|
||||
const sources = configManager.getAllWithSources();
|
||||
const hasCustomIcon =
|
||||
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
|
||||
@@ -2,19 +2,49 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
/**
|
||||
* Split a per-account calendar list into "owned" (the user's own) and
|
||||
* "shared" sub-buckets, then group shared by the owning principal so each
|
||||
* delegator gets its own sub-section.
|
||||
*/
|
||||
type AccountCalendarSplit = {
|
||||
owned: Calendar[];
|
||||
sharedGroups: { label: string; calendars: Calendar[] }[];
|
||||
};
|
||||
|
||||
function splitAccountCalendars(list: Calendar[]): AccountCalendarSplit {
|
||||
const owned: Calendar[] = [];
|
||||
const sharedBuckets = new Map<string, { label: string; calendars: Calendar[] }>();
|
||||
for (const cal of list) {
|
||||
if (cal.isShared) {
|
||||
const key = cal.accountId || cal.accountName || cal.id;
|
||||
const bucket = sharedBuckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.calendars.push(cal);
|
||||
} else {
|
||||
sharedBuckets.set(key, { label: cal.accountName || key, calendars: [cal] });
|
||||
}
|
||||
} else {
|
||||
owned.push(cal);
|
||||
}
|
||||
}
|
||||
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
|
||||
}
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
calendars: Calendar[];
|
||||
selectedCalendarIds: string[];
|
||||
@@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps {
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
/**
|
||||
* When true, render one collapsible section per connected local account,
|
||||
* mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed
|
||||
* by their `localAccountId` and the active account is shown first.
|
||||
*/
|
||||
multiAccountMode?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarSidebarPanel({
|
||||
@@ -43,12 +79,18 @@ export function CalendarSidebarPanel({
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
multiAccountMode,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const currentAccountId = client?.getAccountId();
|
||||
const icalSubscriptions = useMemo(
|
||||
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
|
||||
[allSubs, currentAccountId],
|
||||
);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
@@ -65,6 +107,26 @@ export function CalendarSidebarPanel({
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
|
||||
// Persisted across mounts so toggle state survives tab switches in the
|
||||
// Pro shell (same key family as the mail sidebar's account collapse).
|
||||
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('calendar-sidebar-collapsed-accounts');
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { return new Set(); }
|
||||
});
|
||||
const toggleAccountGroup = (key: string) => {
|
||||
setCollapsedAccountGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
try { localStorage.setItem('calendar-sidebar-collapsed-accounts', JSON.stringify(Array.from(next))); } catch { /* */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const localAccounts = useAccountStore((s) => s.accounts);
|
||||
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
|
||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||
const sharedAccountGroups = useMemo(() => {
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
@@ -79,6 +141,53 @@ export function CalendarSidebarPanel({
|
||||
return Array.from(groups.values());
|
||||
}, [calendars]);
|
||||
|
||||
/**
|
||||
* Pro / multi-account grouping: every calendar bucketed by its owning
|
||||
* local account. Active account comes first, then the rest in their
|
||||
* account-store order. Calendars without a `localAccountId` (e.g. the
|
||||
* birthday calendar) fall into a separate "other" bucket so they still
|
||||
* render.
|
||||
*/
|
||||
const localAccountGroups = useMemo(() => {
|
||||
if (!multiAccountMode) return [];
|
||||
const byAccount = new Map<string, Calendar[]>();
|
||||
for (const cal of calendars) {
|
||||
const key = cal.localAccountId || '__other__';
|
||||
const list = byAccount.get(key) ?? [];
|
||||
list.push(cal);
|
||||
byAccount.set(key, list);
|
||||
}
|
||||
const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = [];
|
||||
// Active account first.
|
||||
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
|
||||
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
|
||||
ordered.push({
|
||||
key: activeLocalAccountId,
|
||||
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
|
||||
split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!),
|
||||
});
|
||||
byAccount.delete(activeLocalAccountId);
|
||||
}
|
||||
// Then the rest in account-store order so the layout matches the mail sidebar.
|
||||
for (const acct of localAccounts) {
|
||||
if (!byAccount.has(acct.id)) continue;
|
||||
ordered.push({
|
||||
key: acct.id,
|
||||
label: acct.label || acct.email || acct.username,
|
||||
split: splitAccountCalendars(byAccount.get(acct.id)!),
|
||||
});
|
||||
byAccount.delete(acct.id);
|
||||
}
|
||||
// Any leftover buckets (deleted accounts, untagged calendars).
|
||||
for (const [key, list] of byAccount.entries()) {
|
||||
const fallbackLabel = key === '__other__'
|
||||
? t('my_calendars')
|
||||
: list[0]?.accountName || key;
|
||||
ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) });
|
||||
}
|
||||
return ordered;
|
||||
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
|
||||
|
||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
};
|
||||
@@ -263,37 +372,110 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
|
||||
{sharedAccountGroups.map((group) => (
|
||||
<div key={group.accountName} className="mt-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
<>
|
||||
{localAccountGroups.map((group, idx) => {
|
||||
const expanded = !collapsedAccountGroups.has(group.key);
|
||||
const isActive = group.key === activeLocalAccountId;
|
||||
const { owned, sharedGroups } = group.split;
|
||||
return (
|
||||
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
|
||||
<button
|
||||
onClick={() => toggleAccountGroup(group.key)}
|
||||
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground/90 truncate">
|
||||
{group.label}
|
||||
</span>
|
||||
{isActive && onCreateCalendar && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCreateCalendar();
|
||||
}
|
||||
}}
|
||||
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-1 pl-3">
|
||||
{owned.length > 0 && (
|
||||
<div>
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{owned.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharedGroups.map((sg) => (
|
||||
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
|
||||
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{sg.label}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{sg.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
|
||||
{sharedAccountGroups.map((group) => (
|
||||
<div key={group.accountName} className="mt-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{group.calendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderCalendarMenu()}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
|
||||
selectedCalendarIds?: string[];
|
||||
onToggleVisibility?: (id: string) => void;
|
||||
enableCalendarTasks?: boolean;
|
||||
/** Show a burger button at the start that opens the (overlay) sidebar. */
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
@@ -45,6 +47,7 @@ export function CalendarToolbar({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
enableCalendarTasks,
|
||||
onMenuClick,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
@@ -115,11 +118,32 @@ export function CalendarToolbar({
|
||||
|
||||
return (
|
||||
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
|
||||
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
|
||||
{onMenuClick && !isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuClick}
|
||||
className="h-8 w-8 -ml-1 mr-1"
|
||||
aria-label={t("nav_open_menu")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* ── MOBILE TOOLBAR ── */}
|
||||
{isMobile && (
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
{/* Row 1: Back / Date nav / Today */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
|
||||
aria-label={t("nav_open_menu")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onNavigateBack && (
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 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 { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
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 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 {
|
||||
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 {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return t("alerts.at_time");
|
||||
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||
return null;
|
||||
const labels: string[] = [];
|
||||
for (const alert of Object.values(event.alerts)) {
|
||||
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
|
||||
const row = offsetToAlertRow(alert.trigger.offset);
|
||||
if (!row) continue;
|
||||
labels.push(formatAlertRowLabel(row, t));
|
||||
}
|
||||
if (labels.length === 0) return null;
|
||||
return labels.join(", ");
|
||||
}
|
||||
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
@@ -216,22 +272,36 @@ export function EventModal({
|
||||
if (!event?.recurrenceRules?.length) return "none";
|
||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
||||
});
|
||||
const [alert, setAlert] = useState<AlertOption>(() => {
|
||||
if (!event?.alerts) return "none";
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first) return "none";
|
||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return "at_time";
|
||||
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||
if (minMatch) return minMatch[1] as AlertOption;
|
||||
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
||||
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||
if (!event?.alerts) return [];
|
||||
const rows: AlertRow[] = [];
|
||||
for (const [id, alert] of Object.entries(event.alerts)) {
|
||||
// Preserve alerts we can't represent in this UI (absolute triggers,
|
||||
// email actions, offsets with non-canonical shapes) so they survive a save.
|
||||
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
|
||||
preservedAlertsRef.current[id] = alert;
|
||||
continue;
|
||||
}
|
||||
const row = offsetToAlertRow(alert.trigger.offset);
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -400,17 +470,23 @@ export function EventModal({
|
||||
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
|
||||
}
|
||||
|
||||
if (alert !== "none") {
|
||||
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`;
|
||||
data.alerts = {
|
||||
alert1: {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
|
||||
let alertIdx = 0;
|
||||
for (const row of alertRows) {
|
||||
const offset = alertRowToOffset(row);
|
||||
if (offset === null) continue;
|
||||
let key = `alert${++alertIdx}`;
|
||||
while (key in builtAlerts) key = `alert${++alertIdx}`;
|
||||
builtAlerts[key] = {
|
||||
"@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) {
|
||||
data.alerts = null;
|
||||
}
|
||||
@@ -435,7 +511,7 @@ export function EventModal({
|
||||
} finally {
|
||||
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']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -987,37 +1063,81 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
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"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
<select
|
||||
value={alert}
|
||||
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"
|
||||
>
|
||||
<option value="none">{t("alerts.none")}</option>
|
||||
<option value="at_time">{t("alerts.at_time")}</option>
|
||||
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option>
|
||||
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option>
|
||||
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option>
|
||||
<option value="60">{t("alerts.hours_before", { count: 1 })}</option>
|
||||
<option value="1440">{t("alerts.days_before", { count: 1 })}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
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"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
{alertRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alertRows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
{row.unit !== "at_time" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
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>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
|
||||
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { normalizeContactPhotoUri } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
|
||||
|
||||
@@ -51,6 +52,8 @@ interface ContactFormProps {
|
||||
addressBooks?: AddressBook[];
|
||||
allKeywords?: string[];
|
||||
defaultAddressBookId?: string;
|
||||
/** Prefills the create form (ignored when `contact` is set). */
|
||||
prefill?: { email?: string; name?: string };
|
||||
onSave: (data: Partial<ContactCard>) => Promise<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 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.
|
||||
const findComponent = (...kinds: string[]) =>
|
||||
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 [givenName, setGivenName] = useState(findComponent("given"));
|
||||
const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
|
||||
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 [nickname, setNickname] = useState(
|
||||
@@ -230,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
|
||||
}));
|
||||
}
|
||||
return [{ address: "", context: "" }];
|
||||
return [{ address: prefill?.email || "", context: "" }];
|
||||
});
|
||||
|
||||
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
|
||||
@@ -341,7 +356,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
if (!contact?.media) return null;
|
||||
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;
|
||||
}, [contact]);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
@@ -112,6 +112,8 @@ interface ContactListProps {
|
||||
onEditContact: (id: string) => void;
|
||||
onDeleteContact: (contact: ContactCard) => void;
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
@@ -133,6 +135,7 @@ export function ContactList({
|
||||
onEditContact,
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
onMenuClick,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const locale = useLocale();
|
||||
@@ -267,6 +270,16 @@ export function ContactList({
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="px-3 py-3">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
|
||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||
|
||||
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
|
||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
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";
|
||||
@@ -70,6 +101,7 @@ export function ContactsSidebar({
|
||||
onDeleteAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
multiAccountMode,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const router = useRouter();
|
||||
@@ -136,6 +168,47 @@ export function ContactsSidebar({
|
||||
return Array.from(map.values());
|
||||
}, [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
|
||||
const contactCountByBook = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
@@ -257,47 +330,117 @@ export function ContactsSidebar({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
{/* Address Books: per-account groups in multi-account Pro mode, else the
|
||||
classic "My Address Books" section. */}
|
||||
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
localAccountGroups.map((group) => {
|
||||
const sectionKey = `account-${group.key}`;
|
||||
const expanded = !collapsed[sectionKey];
|
||||
const { owned, sharedGroups } = group.split;
|
||||
return (
|
||||
<div key={group.key} className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
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">
|
||||
{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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 flex-1 text-left"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<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>
|
||||
{!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 */}
|
||||
@@ -398,8 +541,9 @@ export function ContactsSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
{/* Shared accounts with address books - only when not already split
|
||||
into per-account groups above (multi-account Pro mode). */}
|
||||
{!multiAccountMode && sharedBookGroups.map((group) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<div className="flex items-center px-3 py-1 group">
|
||||
<button
|
||||
|
||||
@@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionError(null);
|
||||
try {
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in
|
||||
// parallel with parsing to save a roundtrip.
|
||||
const [events, rawText] = await Promise.all([
|
||||
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
||||
@@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|
||||
setState('parsed');
|
||||
|
||||
// Hydrate the calendar store with the matching event in the background —
|
||||
// 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.
|
||||
|
||||
@@ -9,11 +9,12 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
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 type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-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 { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
replaceInlineImagePlaceholders,
|
||||
} from "@/lib/email-composer-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
@@ -75,6 +80,11 @@ interface EmailComposerProps {
|
||||
fromName?: string;
|
||||
identityId?: 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 }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
@@ -103,6 +113,14 @@ interface EmailComposerProps {
|
||||
messageId?: string;
|
||||
inReplyTo?: 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>`;
|
||||
const endMarker = `<p data-signature-block="end"></p>`;
|
||||
if (identity?.htmlSignature) {
|
||||
return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`;
|
||||
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature) {
|
||||
const escaped = identity.textSignature
|
||||
@@ -171,8 +189,18 @@ export function EmailComposer({
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
// 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
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
@@ -246,6 +274,12 @@ export function EmailComposer({
|
||||
? `${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') {
|
||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
@@ -266,12 +300,29 @@ export function EmailComposer({
|
||||
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
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><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) {
|
||||
@@ -293,9 +344,17 @@ export function EmailComposer({
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
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 saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
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[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
@@ -354,6 +413,19 @@ export function EmailComposer({
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || 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
|
||||
// identity's signature so replies (which auto-select a matching alias) still
|
||||
// populate the user's signature.
|
||||
@@ -470,8 +542,78 @@ export function EmailComposer({
|
||||
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
|
||||
? `<div>${sanitizeEmailHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<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
|
||||
const saveDraft = async (): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
|
||||
const toAddresses = to.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
|
||||
if (currentData === lastSavedDataRef.current) {
|
||||
return draftId;
|
||||
return draftIdRef.current;
|
||||
}
|
||||
|
||||
setSaveStatus('saving');
|
||||
@@ -916,20 +1058,27 @@ export function EmailComposer({
|
||||
: (currentIdentity?.name || undefined);
|
||||
|
||||
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,
|
||||
subject || t('no_subject'),
|
||||
plainTextMode ? body : htmlToPlainText(body),
|
||||
ccAddresses,
|
||||
bccAddresses,
|
||||
currentIdentity?.id,
|
||||
currentIdentityRawId,
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
previousDraftId || undefined,
|
||||
uploadedAttachments,
|
||||
fromName,
|
||||
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);
|
||||
lastSavedDataRef.current = currentData;
|
||||
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
|
||||
saveDraftRef.current = saveDraft;
|
||||
|
||||
@@ -963,6 +1134,10 @@ export function EmailComposer({
|
||||
|
||||
// Set new timeout for auto-save (2 seconds after last change)
|
||||
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.
|
||||
emailHooks.onDraftChange.emit({
|
||||
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) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
try {
|
||||
const savedId = await saveDraft();
|
||||
if (savedId) {
|
||||
finalDraftId = savedId;
|
||||
}
|
||||
if (savedId) finalDraftId = savedId;
|
||||
} catch (err) {
|
||||
debug.error('Failed to save draft before send:', err);
|
||||
}
|
||||
@@ -1128,7 +1317,7 @@ export function EmailComposer({
|
||||
if (signatureAlreadyInBody) return '';
|
||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`;
|
||||
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (signatureIdentity?.textSignature) {
|
||||
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
|
||||
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
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
@@ -1321,6 +1517,15 @@ export function EmailComposer({
|
||||
};
|
||||
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?.({
|
||||
to: outgoing.to,
|
||||
cc: outgoing.cc,
|
||||
@@ -1331,8 +1536,9 @@ export function EmailComposer({
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName,
|
||||
identityId: outgoing.identityId || currentIdentity?.id,
|
||||
identityId: rawId,
|
||||
envelopeMailFrom,
|
||||
localAccountId: identityLocalAccountId ?? undefined,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
@@ -1356,6 +1562,7 @@ export function EmailComposer({
|
||||
setBcc("");
|
||||
setSubject("");
|
||||
setBody("");
|
||||
draftIdRef.current = null;
|
||||
setDraftId(null);
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
@@ -1501,16 +1708,31 @@ export function EmailComposer({
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{identityGroups.length > 0
|
||||
? identityGroups.map((group) => (
|
||||
<optgroup key={group.localAccountId} label={group.accountLabel}>
|
||||
{group.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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
) : (
|
||||
<span className="text-sm text-foreground flex-1 truncate">
|
||||
|
||||
@@ -21,6 +21,7 @@ interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
@@ -30,7 +31,7 @@ interface EmailListItemProps {
|
||||
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 { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
@@ -125,6 +126,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
@@ -44,6 +45,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
onOpenConversation,
|
||||
@@ -447,6 +449,7 @@ export function EmailList({
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
PenSquare,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
@@ -78,10 +79,11 @@ import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
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 { detectSmime } from "@/lib/smime/smime-detect";
|
||||
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 type { TnefAttachment } from "@/lib/tnef";
|
||||
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 { emailHooks, uiHooks } from "@/lib/plugin-hooks";
|
||||
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||
@@ -921,6 +923,7 @@ export function EmailViewer({
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||
@@ -970,8 +973,8 @@ export function EmailViewer({
|
||||
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
|
||||
|
||||
// Plugin detail sidebar state
|
||||
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
|
||||
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
|
||||
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
|
||||
const hasDetailSidebar = detailSlots.length > 0;
|
||||
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
|
||||
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
|
||||
const detailSidebarWidthRef = useRef(280);
|
||||
@@ -1137,9 +1140,34 @@ export function EmailViewer({
|
||||
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||
const router = useRouter();
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -2535,7 +2563,12 @@ export function EmailViewer({
|
||||
|
||||
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||
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 = {
|
||||
name: attachment.name || '',
|
||||
@@ -2787,7 +2820,9 @@ export function EmailViewer({
|
||||
<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>
|
||||
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; }
|
||||
@media (max-width: 640px) { body { padding-left: 0; padding-right: 0; } }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
@@ -2863,7 +2898,7 @@ export function EmailViewer({
|
||||
// window between selectedEmail changing and isLoading flipping true, so the
|
||||
// quick reply / body don't flicker through a partial render.
|
||||
// An empty bodyValues with no referenced parts means the email has no body
|
||||
// (e.g. calendar-only invites) — not "still loading".
|
||||
// (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));
|
||||
|
||||
@@ -3061,14 +3096,26 @@ export function EmailViewer({
|
||||
if (!email) return;
|
||||
const printSender = email.from?.[0];
|
||||
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 ccList = email.cc?.map(r => r.name ? `${r.name} <${r.email}>` : r.email).join(', ') || '';
|
||||
const formatRecipient = (r: { name?: string | null; email: string }) =>
|
||||
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');
|
||||
if (!printWindow) return;
|
||||
|
||||
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>
|
||||
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; }
|
||||
@@ -3080,15 +3127,15 @@ export function EmailViewer({
|
||||
@media print { body { margin: 20px; } }
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
<div class="subject">${DOMPurify.sanitize(email.subject || t('no_subject'))}</div>
|
||||
<div class="subject">${escapeHtml(subjectText)}</div>
|
||||
<div class="meta">
|
||||
<div><strong>${t('from')}:</strong> ${DOMPurify.sanitize(printSender?.name ? `${printSender.name} <${printSender.email}>` : printSender?.email || t('unknown_sender'))}</div>
|
||||
${toList ? `<div><strong>${t('to')}:</strong> ${toList}</div>` : ''}
|
||||
<div><strong>${escapeHtml(t('from'))}:</strong> ${escapeHtml(senderText)}</div>
|
||||
${toList ? `<div><strong>${escapeHtml(t('to'))}:</strong> ${toList}</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 class="body">${effectiveEmailContent.html}</div>
|
||||
<div class="body">${printableBody}</div>
|
||||
</body></html>`);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
@@ -3208,19 +3255,21 @@ export function EmailViewer({
|
||||
}
|
||||
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="text-center p-8">
|
||||
<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" />
|
||||
{!isEmbedded && (
|
||||
<div className="text-center p-8">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -5154,14 +5203,15 @@ export function EmailViewer({
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
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' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="email-content-text text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: effectiveEmailContent.html }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
|
||||
@@ -39,7 +39,11 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
const phones = contact?.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
|
||||
|
||||
const handleOpen = () => {
|
||||
const handleToggle = () => {
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const popoverWidth = 300;
|
||||
@@ -125,7 +129,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={handleOpen}
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
|
||||
className
|
||||
|
||||
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
parseHTML: (el) => {
|
||||
const dataCid = el.getAttribute("data-cid");
|
||||
if (dataCid) return dataCid;
|
||||
// Fall back to deriving the cid from `src="cid:xxx"` so inline
|
||||
// image refs survive editor round-trips even when data-cid was
|
||||
// never set (defensive — the composer normally pre-rewrites
|
||||
// quoted-body cid: refs into data-cid).
|
||||
const src = el.getAttribute("src") || "";
|
||||
if (/^cid:/i.test(src)) return src.slice(4) || null;
|
||||
return null;
|
||||
},
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -453,6 +453,7 @@ function EmailCard({
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
body { margin: 0; padding: 0; 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; }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
@@ -584,6 +585,7 @@ function EmailCard({
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
title="Email content"
|
||||
className="w-full border-0 block"
|
||||
scrolling="no"
|
||||
style={{ minHeight: '60px' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
@@ -598,7 +600,7 @@ function EmailCard({
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ThreadEmailItemProps {
|
||||
selected?: boolean;
|
||||
isLast?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function ThreadEmailItem({
|
||||
selected,
|
||||
isLast = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
@@ -96,6 +98,12 @@ export function ThreadEmailItem({
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ThreadListItemProps {
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -39,6 +40,7 @@ interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
@@ -51,7 +53,7 @@ interface SingleEmailItemProps {
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -146,6 +148,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -351,6 +359,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
@@ -420,6 +429,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
@@ -506,6 +516,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onEmailDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onEmailDoubleClick(latestEmail);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -762,6 +778,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
|
||||
@@ -21,6 +23,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
|
||||
@@ -35,6 +38,13 @@ interface ClipboardState {
|
||||
sourceParentId: string | null;
|
||||
}
|
||||
|
||||
export interface AccountFolderEntry {
|
||||
accountId: string;
|
||||
label: string;
|
||||
email: string;
|
||||
avatarColor: string;
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
currentPath: string;
|
||||
resources: FileResource[];
|
||||
@@ -78,6 +88,13 @@ interface FileBrowserProps {
|
||||
showDetails: boolean;
|
||||
onToggleDetails: () => void;
|
||||
detailResource: FileResource | null;
|
||||
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
|
||||
accountFolders?: AccountFolderEntry[];
|
||||
onSelectAccount?: (accountId: string) => void;
|
||||
/** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */
|
||||
accountPickerMode?: boolean;
|
||||
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
||||
accountLabel?: string | null;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -321,6 +338,10 @@ export function FileBrowser({
|
||||
onToggleDetails,
|
||||
detailResource,
|
||||
clipboard,
|
||||
accountFolders,
|
||||
onSelectAccount,
|
||||
accountPickerMode,
|
||||
accountLabel,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
@@ -352,6 +373,13 @@ export function FileBrowser({
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(256);
|
||||
const [dragTarget, setDragTarget] = useState<string | null>(null);
|
||||
// Pane-aware: in a Pro split pane (or a narrow window) the folder tree
|
||||
// sidebar collapses into a burger-toggled overlay so it doesn't crowd the
|
||||
// file list.
|
||||
const isDesktopPane = useIsDesktop();
|
||||
const isNarrow = !isDesktopPane;
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
|
||||
// Sync showThumbnails and folderLayout when settings change
|
||||
useEffect(() => {
|
||||
@@ -442,8 +470,10 @@ export function FileBrowser({
|
||||
return sorted;
|
||||
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
||||
|
||||
// Build breadcrumb segments
|
||||
const breadcrumbs = currentPath === '/'
|
||||
// Build breadcrumb segments. In Pro mode an account is mounted "between"
|
||||
// Home and the account's filesystem - surfaced as a non-clickable label
|
||||
// (clicking the actual account again would be a no-op; Home detaches it).
|
||||
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
|
||||
? [{ name: t("breadcrumb_root"), path: '/' }]
|
||||
: [
|
||||
{ name: t("breadcrumb_root"), path: '/' },
|
||||
@@ -452,14 +482,24 @@ export function FileBrowser({
|
||||
path: '/' + arr.slice(0, i + 1).join('/'),
|
||||
})),
|
||||
];
|
||||
if (accountLabel) {
|
||||
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
|
||||
}
|
||||
|
||||
const handleNavigateUp = useCallback(() => {
|
||||
if (currentPath === '/') return;
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
segments.pop();
|
||||
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
||||
onNavigate(parentPath, null);
|
||||
}, [currentPath, onNavigate]);
|
||||
// Pro shell: going up to root from a subfolder must land on the
|
||||
// account's filesystem root, not detach back to the account picker.
|
||||
// Home click (breadcrumb) still detaches.
|
||||
if (parentPath === '/' && accountLabel) {
|
||||
onNavigate('/', '__account_root__');
|
||||
return;
|
||||
}
|
||||
onNavigate(parentPath);
|
||||
}, [currentPath, onNavigate, accountLabel]);
|
||||
|
||||
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
||||
if (resource.isDirectory) {
|
||||
@@ -846,14 +886,27 @@ export function FileBrowser({
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background">
|
||||
{isNarrow && folderLayout === "sidebar" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -ml-2"
|
||||
onClick={() => setNarrowSidebarOpen((v) => !v)}
|
||||
aria-label={t("open_folder_tree")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Breadcrumbs */}
|
||||
<nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1 shrink-0">
|
||||
<span key={`${i}:${crumb.path}`} className="flex items-center gap-1 shrink-0">
|
||||
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
<button
|
||||
onClick={() => onNavigate(crumb.path)}
|
||||
onContextMenu={(e) => handleBreadcrumbRightClick(e, crumb.path)}
|
||||
onClick={() => crumb.isAccount
|
||||
? onNavigate('/', '__account_root__')
|
||||
: onNavigate(crumb.path)}
|
||||
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
|
||||
i === breadcrumbs.length - 1
|
||||
@@ -902,15 +955,17 @@ export function FileBrowser({
|
||||
{t("paste")} ({clipboard.names.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowSearch(v => !v)}
|
||||
title={t("search_placeholder")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
{!accountPickerMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowSearch(v => !v)}
|
||||
title={t("search_placeholder")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -920,62 +975,66 @@ export function FileBrowser({
|
||||
>
|
||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", showDetails && "bg-muted")}
|
||||
onClick={onToggleDetails}
|
||||
title={t("details")}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
|
||||
onClick={() => onToggleFavorite(currentPath)}
|
||||
title={t("toggle_favorite")}
|
||||
>
|
||||
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("upload")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title={t("upload_folder")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<FolderUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewFolder(true)}
|
||||
title={t("new_folder")}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewTextFile(true)}
|
||||
title={t("new_text_file")}
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
</Button>
|
||||
{!accountPickerMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", showDetails && "bg-muted")}
|
||||
onClick={onToggleDetails}
|
||||
title={t("details")}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
|
||||
onClick={() => onToggleFavorite(currentPath)}
|
||||
title={t("toggle_favorite")}
|
||||
>
|
||||
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("upload")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title={t("upload_folder")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<FolderUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewFolder(true)}
|
||||
title={t("new_folder")}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewTextFile(true)}
|
||||
title={t("new_text_file")}
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1100,26 +1159,59 @@ export function FileBrowser({
|
||||
|
||||
{/* File list */}
|
||||
<div className="flex-1 min-h-0 flex relative">
|
||||
{/* Narrow-pane backdrop for the overlay folder tree */}
|
||||
{folderLayout === "sidebar" && isNarrow && narrowSidebarOpen && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 z-40"
|
||||
onClick={() => setNarrowSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Folder tree sidebar (when layout is sidebar) */}
|
||||
{folderLayout === "sidebar" && (
|
||||
<>
|
||||
<FolderTreeSidebar
|
||||
currentPath={currentPath}
|
||||
onNavigate={onNavigate}
|
||||
listByParentId={listByParentId}
|
||||
width={sidebarWidth}
|
||||
isResizing={isResizing}
|
||||
/>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
|
||||
isNarrow ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 z-50",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Auto-close when the user taps a folder name. Chevrons stay
|
||||
// open so they can expand/collapse without dismissing.
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest('button');
|
||||
if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) {
|
||||
setNarrowSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
|
||||
/>
|
||||
</>
|
||||
>
|
||||
<FolderTreeSidebar
|
||||
currentPath={currentPath}
|
||||
onNavigate={onNavigate}
|
||||
listByParentId={listByParentId}
|
||||
width={288}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FolderTreeSidebar
|
||||
currentPath={currentPath}
|
||||
onNavigate={onNavigate}
|
||||
listByParentId={listByParentId}
|
||||
width={sidebarWidth}
|
||||
isResizing={isResizing}
|
||||
/>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsResizing(false);
|
||||
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{/* Favorites & Recent sidebar (when layout is inline) */}
|
||||
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
||||
@@ -1198,6 +1290,41 @@ export function FileBrowser({
|
||||
<SkeletonRow />
|
||||
</tbody>
|
||||
</table>
|
||||
) : accountPickerMode && accountFolders && accountFolders.length > 0 && onSelectAccount ? (
|
||||
/* ======= ACCOUNT PICKER (Pro mode root) ======= */
|
||||
<div className="p-4">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(11rem, 1fr))' }}
|
||||
>
|
||||
{accountFolders.map((acc) => (
|
||||
<button
|
||||
key={`__account__:${acc.accountId}`}
|
||||
onClick={() => onSelectAccount(acc.accountId)}
|
||||
title={acc.email}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
|
||||
>
|
||||
<Avatar
|
||||
name={acc.label}
|
||||
email={acc.email}
|
||||
size="md"
|
||||
fallbackColor={acc.avatarColor}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<span className="truncate text-sm font-medium">{acc.label || acc.email}</span>
|
||||
{acc.label && acc.label !== acc.email && (
|
||||
<span className="truncate text-xs text-muted-foreground">{acc.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : accountPickerMode ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
|
||||
</div>
|
||||
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
|
||||
<FileUploadArea
|
||||
onUpload={async (files: File[]) => {
|
||||
|
||||
@@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
|
||||
"border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
|
||||
!isResizing && "transition-[width] duration-300"
|
||||
)}
|
||||
style={{ width: `${width}px` }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user