feat: expose PWA, app identity, and extension directory keys in JSON config #312
This commit is contained in:
@@ -90,7 +90,7 @@ function OAuthCallbackInner() {
|
|||||||
|
|
||||||
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
|
if (mobileRedirectUri && mobileRedirectUri.startsWith("bulwarkmobile://")) {
|
||||||
// Drive /api/auth/sso/complete directly so we can read the tokens
|
// Drive /api/auth/sso/complete directly so we can read the tokens
|
||||||
// out of the response — loginWithServerSso would consume them and
|
// out of the response - loginWithServerSso would consume them and
|
||||||
// wire up the webmail auth store, which isn't useful here. The
|
// wire up the webmail auth store, which isn't useful here. The
|
||||||
// server's mobile-flow branch (keyed on the pending cookie) skips
|
// server's mobile-flow branch (keyed on the pending cookie) skips
|
||||||
// the refresh-token cookie write for the same reason.
|
// the refresh-token cookie write for the same reason.
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
// Initialize JMAP files client. In the Pro shell, all connected accounts
|
// 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
|
// are surfaced as top-level folders at the root, so we *don't* auto-attach
|
||||||
// to the active account — the user picks one explicitly.
|
// to the active account - the user picks one explicitly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !client || hasFetched.current) return;
|
if (!isAuthenticated || !client || hasFetched.current) return;
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
@@ -397,7 +397,7 @@ export default function FilesPage() {
|
|||||||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||||||
|
|
||||||
// Pro shell only: all connected accounts are equal top-level entries at
|
// Pro shell only: all connected accounts are equal top-level entries at
|
||||||
// the root. The root path "/" itself is a cross-account picker — no
|
// the root. The root path "/" itself is a cross-account picker - no
|
||||||
// account's files are shown until the user enters one.
|
// account's files are shown until the user enters one.
|
||||||
const accountFolders = isEmbedded
|
const accountFolders = isEmbedded
|
||||||
? accounts
|
? accounts
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export default function LoginPage() {
|
|||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
// In mobile-handoff mode the callback page needs to know it should
|
// In mobile-handoff mode the callback page needs to know it should
|
||||||
// redirect into the app rather than into /mail. Stash the params in
|
// redirect into the app rather than into /mail. Stash the params in
|
||||||
// sessionStorage so the same-tab callback can read them — the SSO
|
// sessionStorage so the same-tab callback can read them - the SSO
|
||||||
// pending cookie carries the authoritative copy server-side too.
|
// pending cookie carries the authoritative copy server-side too.
|
||||||
if (isMobileHandoff) {
|
if (isMobileHandoff) {
|
||||||
try {
|
try {
|
||||||
@@ -623,7 +623,7 @@ export default function LoginPage() {
|
|||||||
saveUsername(formData.username);
|
saveUsername(formData.username);
|
||||||
if (isMobileHandoff) {
|
if (isMobileHandoff) {
|
||||||
// The isAuthenticated effect handles the redirect; nothing else to
|
// The isAuthenticated effect handles the redirect; nothing else to
|
||||||
// do here. Don't push to / — that would race the deep link.
|
// do here. Don't push to / - that would race the deep link.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push('/');
|
router.push('/');
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ interface PaneProps {
|
|||||||
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
|
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
|
||||||
const paneRef = useRef<HTMLDivElement | null>(null);
|
const paneRef = useRef<HTMLDivElement | null>(null);
|
||||||
// Measured pane width, published to children via PaneSizeContext so that
|
// Measured pane width, published to children via PaneSizeContext so that
|
||||||
// useDeviceDetection / useIsMobile / etc. branch on pane width — not full
|
// useDeviceDetection / useIsMobile / etc. branch on pane width - not full
|
||||||
// viewport — and inner pages collapse to their mobile/tablet layouts when
|
// viewport - and inner pages collapse to their mobile/tablet layouts when
|
||||||
// the pane is narrow.
|
// the pane is narrow.
|
||||||
const [paneWidth, setPaneWidth] = useState<number | null>(null);
|
const [paneWidth, setPaneWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export default function ProHome() {
|
|||||||
// Stable keys are essential: when the split collapses, the row's child
|
// Stable keys are essential: when the split collapses, the row's child
|
||||||
// list goes from [splitPane, divider, mainPane] (or the leading variant)
|
// list goes from [splitPane, divider, mainPane] (or the leading variant)
|
||||||
// to [mainPane]. Without keys, React would reuse the Pane instance at
|
// to [mainPane]. Without keys, React would reuse the Pane instance at
|
||||||
// index 0 — repurposing the *split* pane's instance into the main pane,
|
// index 0 - repurposing the *split* pane's instance into the main pane,
|
||||||
// which strands the main pane's ResizeObserver/paneWidth on a now-
|
// which strands the main pane's ResizeObserver/paneWidth on a now-
|
||||||
// unmounted DOM node and reparents the mail tab body (causing remount
|
// unmounted DOM node and reparents the mail tab body (causing remount
|
||||||
// + stale "still-narrow" measurements after the split is closed).
|
// + stale "still-narrow" measurements after the split is closed).
|
||||||
@@ -316,7 +316,7 @@ export default function ProHome() {
|
|||||||
<EmbeddedContext.Provider value={true}>
|
<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-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Leftmost Navigation Rail — identical to the standard layout */}
|
{/* Leftmost Navigation Rail - identical to the standard layout */}
|
||||||
<div
|
<div
|
||||||
className="w-14 bg-secondary flex flex-col flex-shrink-0"
|
className="w-14 bg-secondary flex flex-col flex-shrink-0"
|
||||||
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
|
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||||
@@ -357,7 +357,7 @@ export default function ProHome() {
|
|||||||
onDragStateChange={setIsTabDragging}
|
onDragStateChange={setIsTabDragging}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panes container — accepts body drops for split/move. */}
|
{/* Panes container - accepts body drops for split/move. */}
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
|
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
|
||||||
onDragOver={handleBodyDragOver}
|
onDragOver={handleBodyDragOver}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { apiFetch } from '@/lib/browser-navigation';
|
|||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
|
// 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.
|
// the raw secret to the client.
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
source: 'admin' | 'env' | 'default';
|
source: 'admin' | 'env' | 'default';
|
||||||
@@ -271,7 +271,7 @@ export function AuthTab() {
|
|||||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" 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 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 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:..." />
|
<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:..." />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
|
|||||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
// Strings that count as "no real secret configured" — used so the dashboard
|
// Strings that count as "no real secret configured" - used so the dashboard
|
||||||
// can warn about a placeholder session secret without us ever returning the
|
// can warn about a placeholder session secret without us ever returning the
|
||||||
// raw value to the client.
|
// raw value to the client.
|
||||||
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
const SENSITIVE_PLACEHOLDERS = new Set(['your-secret-key-here']);
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import {
|
|||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
async function getDirectoryUrl(): Promise<string> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
const MAX_PREVIEW_SOURCE_LEN = 100_000;
|
||||||
|
|
||||||
@@ -27,9 +31,10 @@ export async function GET(
|
|||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const directoryUrl = await getDirectoryUrl();
|
||||||
|
|
||||||
// 1. Extension metadata + screenshots + theme previews from the directory
|
// 1. Extension metadata + screenshots + theme previews from the directory
|
||||||
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, DIRECTORY_URL);
|
const detailUrl = new URL(`/api/v1/extension/${encodeURIComponent(slug)}`, directoryUrl);
|
||||||
const detailRes = await fetch(detailUrl.toString(), {
|
const detailRes = await fetch(detailUrl.toString(), {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
@@ -63,7 +68,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const bundleUrl = new URL(
|
const bundleUrl = new URL(
|
||||||
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(latestVersion)}`,
|
||||||
DIRECTORY_URL,
|
directoryUrl,
|
||||||
);
|
);
|
||||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
@@ -151,7 +156,7 @@ export async function GET(
|
|||||||
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
// 4. Build screenshot URLs (proxy through the directory's public files endpoint).
|
||||||
const screenshots = Array.isArray(extension.screenshots)
|
const screenshots = Array.isArray(extension.screenshots)
|
||||||
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
? (extension.screenshots as Array<{ path: string; altText?: string | null }>).map((s) => ({
|
||||||
url: new URL(`/api/v1/files/${s.path}`, DIRECTORY_URL).toString(),
|
url: new URL(`/api/v1/files/${s.path}`, directoryUrl).toString(),
|
||||||
altText: s.altText ?? null,
|
altText: s.altText ?? null,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
@@ -170,7 +175,7 @@ export async function GET(
|
|||||||
|
|
||||||
const fileUrl = (path: unknown): string | null =>
|
const fileUrl = (path: unknown): string | null =>
|
||||||
typeof path === 'string' && path
|
typeof path === 'string' && path
|
||||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ import {
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'https://extensions.bulwarkmail.org';
|
async function getDirectoryUrl(): Promise<string> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||||
@@ -31,8 +35,9 @@ export async function GET(request: NextRequest) {
|
|||||||
const result = await requireAdminAuth(request);
|
const result = await requireAdminAuth(request);
|
||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const directoryUrl = await getDirectoryUrl();
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const url = new URL('/api/v1/extensions', DIRECTORY_URL);
|
const url = new URL('/api/v1/extensions', directoryUrl);
|
||||||
|
|
||||||
// Forward all search params
|
// Forward all search params
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
@@ -64,7 +69,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const fileUrl = (path: unknown): string | null =>
|
const fileUrl = (path: unknown): string | null =>
|
||||||
typeof path === 'string' && path
|
typeof path === 'string' && path
|
||||||
? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
|
? new URL(`/api/v1/files/${path}`, directoryUrl).toString()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
@@ -108,7 +113,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download the bundle from the directory
|
// Download the bundle from the directory
|
||||||
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL);
|
const directoryUrl = await getDirectoryUrl();
|
||||||
|
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
|
||||||
const bundleRes = await fetch(bundleUrl.toString(), {
|
const bundleRes = await fetch(bundleUrl.toString(), {
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const IMPERSONATION_SLOT = 0;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Impersonation cookies deliberately omit Max-Age so the browser treats
|
* 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
|
* closes the browser, not 30 days later. Impersonation is a temporary
|
||||||
* support handoff; a normal password login is the only thing that should
|
* support handoff; a normal password login is the only thing that should
|
||||||
* survive a browser restart.
|
* survive a browser restart.
|
||||||
@@ -48,7 +48,7 @@ function impersonationCookieOptions() {
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const config = readImpersonationConfig();
|
const config = readImpersonationConfig();
|
||||||
if (!config) {
|
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 });
|
return new NextResponse('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ export async function GET(request: NextRequest) {
|
|||||||
authHeader,
|
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', {
|
logger.info('Impersonation session granted', {
|
||||||
event: 'impersonation_granted',
|
event: 'impersonation_granted',
|
||||||
jti: claims.jti,
|
jti: claims.jti,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||||
|
|
||||||
// For the mobile handoff flow the tokens are handed back to the app
|
// For the mobile handoff flow the tokens are handed back to the app
|
||||||
// verbatim — we deliberately don't write any cookies on the webmail
|
// verbatim - we deliberately don't write any cookies on the webmail
|
||||||
// origin (the mobile browser tab disposes of the session after the
|
// origin (the mobile browser tab disposes of the session after the
|
||||||
// redirect anyway, but the cookie would still get committed to 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).
|
// user's main webmail session if they happened to be logged in there).
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||||
// Mobile params are captured here so /complete knows to return tokens to
|
// Mobile params are captured here so /complete knows to return tokens to
|
||||||
// the caller (in the JSON response) instead of writing the usual server
|
// the caller (in the JSON response) instead of writing the usual server
|
||||||
// cookies — and so the callback page can redirect back to the app.
|
// cookies - and so the callback page can redirect back to the app.
|
||||||
const pendingData = {
|
const pendingData = {
|
||||||
state,
|
state,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { logger } from '@/lib/logger';
|
|||||||
*
|
*
|
||||||
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
|
* Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the
|
||||||
* sandboxed plugin loader can verify bundle signatures before evaluation.
|
* sandboxed plugin loader can verify bundle signatures before evaluation.
|
||||||
* Public — every logged-in user needs to fetch it on app boot.
|
* 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
|
* 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
|
* deletes the on-disk PEM), but we keep it `no-store` for simplicity. The
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
const VALID_SIZES = new Set([192, 512]);
|
const VALID_SIZES = new Set([192, 512]);
|
||||||
|
|
||||||
@@ -32,7 +33,11 @@ export async function GET(
|
|||||||
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
await configManager.ensureLoaded();
|
||||||
|
const sources = configManager.getAllWithSources();
|
||||||
|
const iconUrl =
|
||||||
|
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
|
||||||
|
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
|
||||||
if (!iconUrl) {
|
if (!iconUrl) {
|
||||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
// 1. Provision the admin account. An admin.json file may already exist
|
// 1. Provision the admin account. An admin.json file may already exist
|
||||||
// from a previous ADMIN_PASSWORD env var or an aborted earlier wizard
|
// 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
|
// password as authoritative in that case. The finish route is gated
|
||||||
// by the bootstrap state + one-time setup token, so this is safe.
|
// by the bootstrap state + one-time setup token, so this is safe.
|
||||||
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
|
const created = await setInitialAdminPassword(adminPassword, { allowOverwrite: true });
|
||||||
|
|||||||
+16
-9
@@ -1,4 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,22 +22,28 @@ type ExtendedManifest = MetadataRoute.Manifest & {
|
|||||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||||
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||||
|
|
||||||
export default function manifest(): ExtendedManifest {
|
export default async function manifest(): Promise<ExtendedManifest> {
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
|
||||||
const appName =
|
const appName =
|
||||||
process.env.APP_NAME ||
|
configManager.get<string>("appName") ||
|
||||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||||
"Bulwark Webmail";
|
"Bulwark Webmail";
|
||||||
|
|
||||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
const shortName = configManager.get<string>("appShortName") || appName;
|
||||||
const description =
|
const description =
|
||||||
process.env.APP_DESCRIPTION ||
|
configManager.get<string>("appDescription") ||
|
||||||
"A modern webmail client built for Stalwart Mail Server";
|
"A modern webmail client built for Stalwart Mail Server";
|
||||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
|
||||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
|
||||||
|
|
||||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
|
||||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
|
||||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
|
||||||
|
// are the built-in placeholder paths and not real custom icons.
|
||||||
|
const sources = configManager.getAllWithSources();
|
||||||
|
const hasCustomIcon =
|
||||||
|
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
|
||||||
|
|
||||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ export function ContactsSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shared accounts with address books — only when not already split
|
{/* Shared accounts with address books - only when not already split
|
||||||
into per-account groups above (multi-account Pro mode). */}
|
into per-account groups above (multi-account Pro mode). */}
|
||||||
{!multiAccountMode && sharedBookGroups.map((group) => (
|
{!multiAccountMode && sharedBookGroups.map((group) => (
|
||||||
<div key={group.accountId} className="mt-2">
|
<div key={group.accountId} className="mt-2">
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
setActionError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
// 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.
|
// parallel with parsing to save a roundtrip.
|
||||||
const [events, rawText] = await Promise.all([
|
const [events, rawText] = await Promise.all([
|
||||||
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
||||||
@@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
|
|
||||||
setState('parsed');
|
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.
|
// 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
|
// Filter by UID server-side; the previous unfiltered query fetched up to
|
||||||
// 1000 events plus multiple /get batches just to find one match.
|
// 1000 events plus multiple /get batches just to find one match.
|
||||||
|
|||||||
@@ -982,7 +982,7 @@ export function EmailComposer({
|
|||||||
try {
|
try {
|
||||||
const previousDraftId = draftIdRef.current;
|
const previousDraftId = draftIdRef.current;
|
||||||
// Use the JMAP client and raw identity id for the *owning* account
|
// Use the JMAP client and raw identity id for the *owning* account
|
||||||
// — falls back to active client for single-account / same-account
|
// - falls back to active client for single-account / same-account
|
||||||
// identities. See `composerClient` derivation above.
|
// identities. See `composerClient` derivation above.
|
||||||
const savedDraftId = await composerClient.createDraft(
|
const savedDraftId = await composerClient.createDraft(
|
||||||
toAddresses,
|
toAddresses,
|
||||||
@@ -1287,7 +1287,7 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||||
// S/MIME keys are scoped to one JMAP account's identity — sending
|
// S/MIME keys are scoped to one JMAP account's identity - sending
|
||||||
// from a cross-account identity via S/MIME would mix accounts'
|
// from a cross-account identity via S/MIME would mix accounts'
|
||||||
// certs/clients. Refuse upfront and tell the user to switch.
|
// certs/clients. Refuse upfront and tell the user to switch.
|
||||||
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
|
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
|
||||||
@@ -1440,7 +1440,7 @@ export function EmailComposer({
|
|||||||
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
|
||||||
|
|
||||||
// Strip the cross-account namespace from the identity id before
|
// Strip the cross-account namespace from the identity id before
|
||||||
// handing it to the parent — the JMAP server only knows the raw
|
// handing it to the parent - the JMAP server only knows the raw
|
||||||
// id. The owning local account travels alongside so the parent
|
// id. The owning local account travels alongside so the parent
|
||||||
// can route the send through the right client.
|
// can route the send through the right client.
|
||||||
const rawIdentityId = outgoing.identityId || currentIdentity?.id;
|
const rawIdentityId = outgoing.identityId || currentIdentity?.id;
|
||||||
|
|||||||
@@ -1142,7 +1142,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||||
if (isMobileDevice) {
|
if (isMobileDevice) {
|
||||||
// No room for a sidebar on mobile — send the user to the contacts page
|
// 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
|
// with params describing what to show. The `from=email` flag turns the
|
||||||
// page's mobile back button into a router.back() that returns here.
|
// page's mobile back button into a router.back() that returns here.
|
||||||
const allRecipients = [
|
const allRecipients = [
|
||||||
@@ -2896,7 +2896,7 @@ export function EmailViewer({
|
|||||||
// window between selectedEmail changing and isLoading flipping true, so the
|
// window between selectedEmail changing and isLoading flipping true, so the
|
||||||
// quick reply / body don't flicker through a partial render.
|
// quick reply / body don't flicker through a partial render.
|
||||||
// An empty bodyValues with no referenced parts means the email has no body
|
// 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 hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
|
||||||
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
|
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ interface FileBrowserProps {
|
|||||||
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
|
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
|
||||||
accountFolders?: AccountFolderEntry[];
|
accountFolders?: AccountFolderEntry[];
|
||||||
onSelectAccount?: (accountId: string) => void;
|
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. */
|
/** 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;
|
accountPickerMode?: boolean;
|
||||||
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
||||||
accountLabel?: string | null;
|
accountLabel?: string | null;
|
||||||
@@ -471,7 +471,7 @@ export function FileBrowser({
|
|||||||
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
||||||
|
|
||||||
// Build breadcrumb segments. In Pro mode an account is mounted "between"
|
// Build breadcrumb segments. In Pro mode an account is mounted "between"
|
||||||
// Home and the account's filesystem — surfaced as a non-clickable label
|
// 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).
|
// (clicking the actual account again would be a no-op; Home detaches it).
|
||||||
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
|
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
|
||||||
? [{ name: t("breadcrumb_root"), path: '/' }]
|
? [{ name: t("breadcrumb_root"), path: '/' }]
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface NavigationRailProps {
|
|||||||
activeAppId?: string | null;
|
activeAppId?: string | null;
|
||||||
/**
|
/**
|
||||||
* If provided, intercepts the rail's built-in route navigation. Return
|
* If provided, intercepts the rail's built-in route navigation. Return
|
||||||
* `true` to prevent the underlying `<Link>` from navigating — used by the
|
* `true` to prevent the underlying `<Link>` from navigating - used by the
|
||||||
* Pro interface to open the route as a tab instead. The visual rail is
|
* Pro interface to open the route as a tab instead. The visual rail is
|
||||||
* unchanged.
|
* unchanged.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ interface SidebarProps {
|
|||||||
/**
|
/**
|
||||||
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
|
||||||
* sidebar renders a per-connected-account group instead of a single
|
* sidebar renders a per-connected-account group instead of a single
|
||||||
* folders section — Thunderbird-style. `accountMailboxes` provides the
|
* folders section - Thunderbird-style. `accountMailboxes` provides the
|
||||||
* mailbox list for non-active accounts (the active account still flows
|
* mailbox list for non-active accounts (the active account still flows
|
||||||
* through the `mailboxes` prop). `viewingAccountId` highlights which
|
* through the `mailboxes` prop). `viewingAccountId` highlights which
|
||||||
* account's folder is currently selected (null = active account).
|
* account's folder is currently selected (null = active account).
|
||||||
@@ -715,7 +715,7 @@ export function Sidebar({
|
|||||||
} catch { return new Set(); }
|
} catch { return new Set(); }
|
||||||
});
|
});
|
||||||
// Per-connected-account collapse state for Pro / Thunderbird-style mode.
|
// Per-connected-account collapse state for Pro / Thunderbird-style mode.
|
||||||
// Stored as the set of accountIds the user has explicitly collapsed —
|
// Stored as the set of accountIds the user has explicitly collapsed -
|
||||||
// anything not in the set is treated as expanded. Inverting the storage
|
// anything not in the set is treated as expanded. Inverting the storage
|
||||||
// model lets new accounts default to expanded automatically.
|
// model lets new accounts default to expanded automatically.
|
||||||
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
||||||
@@ -738,7 +738,7 @@ export function Sidebar({
|
|||||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
const connectedAccounts = accounts.filter(a => a.isConnected);
|
||||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||||
// 2+ account requirement still applies — with a single account the
|
// 2+ account requirement still applies - with a single account the
|
||||||
// unified counts would just duplicate that account's inbox.
|
// unified counts would just duplicate that account's inbox.
|
||||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||||
const { unifiedCounts } = useEmailStore();
|
const { unifiedCounts } = useEmailStore();
|
||||||
@@ -930,7 +930,7 @@ export function Sidebar({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header — hidden in the Pro shell, which owns its own chrome and
|
{/* Header - hidden in the Pro shell, which owns its own chrome and
|
||||||
would otherwise render an empty strip (no collapse, no switcher). */}
|
would otherwise render an empty strip (no collapse, no switcher). */}
|
||||||
{!isEmbedded && (
|
{!isEmbedded && (
|
||||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after
|
// Sandboxed slot mount. One iframe per (plugin, slot) - created lazily after
|
||||||
// the background instance confirms `shouldShow(context)` (if defined). The
|
// the background instance confirms `shouldShow(context)` (if defined). The
|
||||||
// iframe renders the plugin's slot component using the plugin's bundle in a
|
// iframe renders the plugin's slot component using the plugin's bundle in a
|
||||||
// null-origin context; its height is pushed back via postMessage and applied
|
// null-origin context; its height is pushed back via postMessage and applied
|
||||||
@@ -59,7 +59,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
|||||||
try { inst.destroy(); } catch { /* ignore */ }
|
try { inst.destroy(); } catch { /* ignore */ }
|
||||||
instanceRef.current = null;
|
instanceRef.current = null;
|
||||||
};
|
};
|
||||||
// We intentionally don't depend on extraProps here — propagating prop
|
// We intentionally don't depend on extraProps here - propagating prop
|
||||||
// changes happens via postMessage below to avoid iframe churn.
|
// changes happens via postMessage below to avoid iframe churn.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [show, pluginId, slot]);
|
}, [show, pluginId, slot]);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface ProComposeTabBodyProps {
|
|||||||
/**
|
/**
|
||||||
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
|
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
|
||||||
* draft autosave, and discard all flow through the shared `email-store`, so
|
* draft autosave, and discard all flow through the shared `email-store`, so
|
||||||
* the result is identical to composing inline in the mail page — the
|
* the result is identical to composing inline in the mail page - the
|
||||||
* composer is just hosted in its own tab instead of in the right pane.
|
* composer is just hosted in its own tab instead of in the right pane.
|
||||||
*/
|
*/
|
||||||
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a single email in its own Pro tab. Fetches the email content on
|
* Renders a single email in its own Pro tab. Fetches the email content on
|
||||||
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient —
|
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient -
|
||||||
* it doesn't depend on what the Mail tab has selected.
|
* it doesn't depend on what the Mail tab has selected.
|
||||||
*/
|
*/
|
||||||
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||||
@@ -160,7 +160,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|||||||
if (!client || !email) return;
|
if (!client || !email) return;
|
||||||
try {
|
try {
|
||||||
await toggleStar(client, email.id);
|
await toggleStar(client, email.id);
|
||||||
// Reflect locally — the viewer re-reads from email-store's selectedEmail
|
// Reflect locally - the viewer re-reads from email-store's selectedEmail
|
||||||
// shape only for the mail tab; here we update our local copy too.
|
// shape only for the mail tab; here we update our local copy too.
|
||||||
setEmail((prev) => prev ? {
|
setEmail((prev) => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const STANDARD_PATH_TO_TAB: Record<string, Exclude<ProTabKind, 'compose' | 'emai
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* When the Pro interface is enabled, the standard mail/calendar/contacts/
|
* When the Pro interface is enabled, the standard mail/calendar/contacts/
|
||||||
* files/settings routes are taken over by the Pro shell — the user shouldn't
|
* files/settings routes are taken over by the Pro shell - the user shouldn't
|
||||||
* have to click "Open" in settings to land there. Mobile/tablet keeps the
|
* have to click "Open" in settings to land there. Mobile/tablet keeps the
|
||||||
* standard layout because Pro is desktop-only (see pro/page.tsx).
|
* standard layout because Pro is desktop-only (see pro/page.tsx).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!embeddedMode || !isEmbedded()) return;
|
if (!embeddedMode || !isEmbedded()) return;
|
||||||
// Refuse to attach the listener without a pinned parent origin —
|
// Refuse to attach the listener without a pinned parent origin -
|
||||||
// otherwise any cross-origin frame could forge sso:trigger-logout.
|
// otherwise any cross-origin frame could forge sso:trigger-logout.
|
||||||
if (!parentOrigin) {
|
if (!parentOrigin) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createContext, useContext } from "react";
|
|||||||
* read this to hide their own NavigationRail and let the shell own the
|
* read this to hide their own NavigationRail and let the shell own the
|
||||||
* chrome.
|
* chrome.
|
||||||
*
|
*
|
||||||
* Provided via context by the Pro shell — no URL coupling, no iframe.
|
* Provided via context by the Pro shell - no URL coupling, no iframe.
|
||||||
*/
|
*/
|
||||||
export const EmbeddedContext = createContext<boolean>(false);
|
export const EmbeddedContext = createContext<boolean>(false);
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function useMediaQuery(query: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* When the Pro shell renders a page inside a (possibly split) pane, that pane
|
* When the Pro shell renders a page inside a (possibly split) pane, that pane
|
||||||
* publishes its measured width via `PaneSizeContext`. Inner pages should
|
* publishes its measured width via `PaneSizeContext`. Inner pages should
|
||||||
* branch their layout against the pane width — not the full viewport — so a
|
* branch their layout against the pane width - not the full viewport - so a
|
||||||
* narrow pane gets the mobile/tablet layout instead of overflowing.
|
* narrow pane gets the mobile/tablet layout instead of overflowing.
|
||||||
*
|
*
|
||||||
* Returns `null` when no pane size is published, signalling the caller to
|
* Returns `null` when no pane size is published, signalling the caller to
|
||||||
@@ -63,7 +63,7 @@ function classifyPane(paneWidth: number | null) {
|
|||||||
*
|
*
|
||||||
* When invoked inside a Pro pane, the returned values reflect the pane's
|
* When invoked inside a Pro pane, the returned values reflect the pane's
|
||||||
* width instead of the window's. The global UI store is NOT updated in that
|
* width instead of the window's. The global UI store is NOT updated in that
|
||||||
* case — two split panes would otherwise fight to write conflicting values,
|
* case - two split panes would otherwise fight to write conflicting values,
|
||||||
* and the store is meant to mirror the actual viewport for callers that read
|
* and the store is meant to mirror the actual viewport for callers that read
|
||||||
* it directly (mobile navigation helpers etc.).
|
* it directly (mobile navigation helpers etc.).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createContext, useContext } from "react";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Width of the pane that's hosting the current subtree, in CSS pixels.
|
* Width of the pane that's hosting the current subtree, in CSS pixels.
|
||||||
* `null` means "no pane is providing a size" — fall back to viewport-based
|
* `null` means "no pane is providing a size" - fall back to viewport-based
|
||||||
* media queries. Set by the Pro shell on each split pane via ResizeObserver.
|
* media queries. Set by the Pro shell on each split pane via ResizeObserver.
|
||||||
*/
|
*/
|
||||||
export const PaneSizeContext = createContext<number | null>(null);
|
export const PaneSizeContext = createContext<number | null>(null);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* When the Pro shell is the active interface, aggregate calendars from
|
* When the Pro shell is the active interface, aggregate calendars from
|
||||||
* every connected account so the calendar sidebar lists them all — the
|
* every connected account so the calendar sidebar lists them all - the
|
||||||
* same way [[use-pro-multi-account-mailboxes]] does for mail folders.
|
* same way [[use-pro-multi-account-mailboxes]] does for mail folders.
|
||||||
*
|
*
|
||||||
* Returns the resolved list of `{ localAccountId, client }` pairs so the
|
* Returns the resolved list of `{ localAccountId, client }` pairs so the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|||||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pro-shell counterpart to [[useProMultiAccountCalendars]] — aggregates
|
* Pro-shell counterpart to [[useProMultiAccountCalendars]] - aggregates
|
||||||
* contacts and address books from every connected JMAP account so the
|
* contacts and address books from every connected JMAP account so the
|
||||||
* contacts sidebar lists them all, grouped by local account.
|
* contacts sidebar lists them all, grouped by local account.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: s
|
|||||||
/**
|
/**
|
||||||
* Pro shell only: load identities from every connected account and group
|
* Pro shell only: load identities from every connected account and group
|
||||||
* them by local account so the composer's From dropdown can render an
|
* them by local account so the composer's From dropdown can render an
|
||||||
* <optgroup> per account — mirrors [[useProMultiAccountCalendars]] and
|
* <optgroup> per account - mirrors [[useProMultiAccountCalendars]] and
|
||||||
* [[useProMultiAccountContacts]].
|
* [[useProMultiAccountContacts]].
|
||||||
*
|
*
|
||||||
* Outside Pro / embedded mode the hook returns `enabled: false` and the
|
* Outside Pro / embedded mode the hook returns `enabled: false` and the
|
||||||
@@ -82,7 +82,7 @@ export function useProMultiAccountIdentities(): {
|
|||||||
const list = await client.getIdentities();
|
const list = await client.getIdentities();
|
||||||
if (!cancelled) next[account.id] = list;
|
if (!cancelled) next[account.id] = list;
|
||||||
} catch {
|
} catch {
|
||||||
// Skip accounts that fail to load identities — one bad
|
// Skip accounts that fail to load identities - one bad
|
||||||
// account shouldn't blank the whole dropdown.
|
// account shouldn't blank the whole dropdown.
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('impersonationReplayCache', () => {
|
|||||||
it('prunes expired jtis on next consume', () => {
|
it('prunes expired jtis on next consume', () => {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
|
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
|
||||||
// Far in the future — pruning should clear the old entry.
|
// Far in the future - pruning should clear the old entry.
|
||||||
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
|
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
|
||||||
// Re-using the old jti is allowed after pruning (security irrelevant since
|
// Re-using the old jti is allowed after pruning (security irrelevant since
|
||||||
// the token would fail signature/exp validation upstream).
|
// the token would fail signature/exp validation upstream).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// run.
|
// run.
|
||||||
//
|
//
|
||||||
// Each entry has one of three states: 'pending' (user installed, waiting for
|
// Each entry has one of three states: 'pending' (user installed, waiting for
|
||||||
// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we
|
// admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we
|
||||||
// don't keep asking).
|
// don't keep asking).
|
||||||
|
|
||||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
|
|
||||||
// Hash from the exact bytes the bundle endpoint will serve so the client's
|
// Hash from the exact bytes the bundle endpoint will serve so the client's
|
||||||
// verifyBundle check passes. For src/ sources that means running esbuild
|
// verifyBundle check passes. For src/ sources that means running esbuild
|
||||||
// here too — slightly more work per manifest list, but unavoidable since
|
// here too - slightly more work per manifest list, but unavoidable since
|
||||||
// the source hash wouldn't match the served bundle.
|
// the source hash wouldn't match the served bundle.
|
||||||
let bundleHash: string;
|
let bundleHash: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded
|
// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded
|
||||||
// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators
|
// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators
|
||||||
// who want to pin the key out-of-band can drop a pre-generated PEM at that
|
// who want to pin the key out-of-band can drop a pre-generated PEM at that
|
||||||
// path before first boot — the loader just reads what's there.
|
// path before first boot - the loader just reads what's there.
|
||||||
|
|
||||||
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
|
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
|
||||||
import { readFile, writeFile, chmod } from 'node:fs/promises';
|
import { readFile, writeFile, chmod } from 'node:fs/promises';
|
||||||
|
|||||||
@@ -128,11 +128,16 @@ export interface AuditEntry {
|
|||||||
/** Config keys that map to environment variables */
|
/** Config keys that map to environment variables */
|
||||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||||
|
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
|
||||||
|
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
|
||||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||||
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
|
||||||
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
|
||||||
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
|
||||||
|
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
|
||||||
|
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||||
|
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
|
||||||
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
|
||||||
appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' },
|
appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' },
|
||||||
loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' },
|
loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' },
|
||||||
@@ -159,6 +164,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||||
|
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Keys that should never be exposed to the client config endpoint */
|
/** Keys that should never be exposed to the client config endpoint */
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function verifyImpersonationJwt(
|
|||||||
}
|
}
|
||||||
const [headerB64, payloadB64, sigB64] = parts;
|
const [headerB64, payloadB64, sigB64] = parts;
|
||||||
|
|
||||||
// Header — reject anything but HS256 BEFORE attempting signature verification.
|
// Header - reject anything but HS256 BEFORE attempting signature verification.
|
||||||
const header = parseSegment(headerB64) as Record<string, unknown>;
|
const header = parseSegment(headerB64) as Record<string, unknown>;
|
||||||
if (header.alg !== 'HS256') {
|
if (header.alg !== 'HS256') {
|
||||||
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
|
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
|
||||||
@@ -91,7 +91,7 @@ export function verifyImpersonationJwt(
|
|||||||
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
|
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature — constant-time compare.
|
// Signature - constant-time compare.
|
||||||
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
|
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
|
||||||
const provided = base64UrlDecode(sigB64);
|
const provided = base64UrlDecode(sigB64);
|
||||||
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
||||||
@@ -109,7 +109,7 @@ export function verifyImpersonationJwt(
|
|||||||
const jti = assertString(payload.jti, 'jti');
|
const jti = assertString(payload.jti, 'jti');
|
||||||
const mailbox = assertString(payload.mailbox, 'mailbox');
|
const mailbox = assertString(payload.mailbox, 'mailbox');
|
||||||
|
|
||||||
// Mailbox MUST NOT contain '%' or ':' — those would inject into the
|
// Mailbox MUST NOT contain '%' or ':' - those would inject into the
|
||||||
// master-user auth header.
|
// master-user auth header.
|
||||||
if (mailbox.includes('%') || mailbox.includes(':')) {
|
if (mailbox.includes('%') || mailbox.includes(':')) {
|
||||||
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
|
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
|
||||||
@@ -126,7 +126,7 @@ export function verifyImpersonationJwt(
|
|||||||
if (iat - CLOCK_SKEW_SEC > nowSec) {
|
if (iat - CLOCK_SKEW_SEC > nowSec) {
|
||||||
throw new ImpersonationJwtError('iat', 'Token issued in the future');
|
throw new ImpersonationJwtError('iat', 'Token issued in the future');
|
||||||
}
|
}
|
||||||
// Hard ceiling on lifetime — refuse long-lived handoff tokens even if the
|
// Hard ceiling on lifetime - refuse long-lived handoff tokens even if the
|
||||||
// signer asked for one.
|
// signer asked for one.
|
||||||
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
|
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
|
||||||
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
|
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
|
||||||
@@ -158,7 +158,7 @@ class ReplayCache {
|
|||||||
this.prune(now);
|
this.prune(now);
|
||||||
if (this.entries.has(jti)) return false;
|
if (this.entries.has(jti)) return false;
|
||||||
if (this.entries.size >= REPLAY_CACHE_MAX) {
|
if (this.entries.size >= REPLAY_CACHE_MAX) {
|
||||||
// Evict the oldest entry — Map preserves insertion order.
|
// Evict the oldest entry - Map preserves insertion order.
|
||||||
const first = this.entries.keys().next().value;
|
const first = this.entries.keys().next().value;
|
||||||
if (first !== undefined) this.entries.delete(first);
|
if (first !== undefined) this.entries.delete(first);
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ class ReplayCache {
|
|||||||
if (exp + CLOCK_SKEW_SEC < now) {
|
if (exp + CLOCK_SKEW_SEC < now) {
|
||||||
this.entries.delete(jti);
|
this.entries.delete(jti);
|
||||||
} else {
|
} else {
|
||||||
// Insertion order means later entries are no older than this one — but
|
// Insertion order means later entries are no older than this one - but
|
||||||
// exp isn't strictly monotonic with insertion, so we can't break here.
|
// exp isn't strictly monotonic with insertion, so we can't break here.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface ImpersonationConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns null when impersonation is not configured — the route MUST surface
|
* Returns null when impersonation is not configured - the route MUST surface
|
||||||
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
|
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
|
||||||
*
|
*
|
||||||
* Required env:
|
* Required env:
|
||||||
@@ -38,7 +38,7 @@ export function readImpersonationConfig(): ImpersonationConfig | null {
|
|||||||
* legacy env fallbacks. Returns null if none is configured.
|
* legacy env fallbacks. Returns null if none is configured.
|
||||||
*
|
*
|
||||||
* The impersonation flow is server-to-server (no user input), so we never
|
* The impersonation flow is server-to-server (no user input), so we never
|
||||||
* accept a custom endpoint — only admin-configured URLs.
|
* accept a custom endpoint - only admin-configured URLs.
|
||||||
*/
|
*/
|
||||||
export async function resolveImpersonationServerUrl(): Promise<string | null> {
|
export async function resolveImpersonationServerUrl(): Promise<string | null> {
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
|
|||||||
+4
-4
@@ -159,7 +159,7 @@ export interface Identity {
|
|||||||
textSignature?: string;
|
textSignature?: string;
|
||||||
htmlSignature?: string;
|
htmlSignature?: string;
|
||||||
mayDelete: boolean;
|
mayDelete: boolean;
|
||||||
// See `Calendar.localAccountId` — set when the Pro shell aggregates
|
// See `Calendar.localAccountId` - set when the Pro shell aggregates
|
||||||
// identities from multiple connected accounts so we can route sends
|
// identities from multiple connected accounts so we can route sends
|
||||||
// back through the owning JMAP client. `accountName` is the
|
// back through the owning JMAP client. `accountName` is the
|
||||||
// user-facing label for the dropdown's optgroup.
|
// user-facing label for the dropdown's optgroup.
|
||||||
@@ -178,7 +178,7 @@ export interface ContactCard {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
// Local account-store ID — set when the Pro shell aggregates contacts
|
// Local account-store ID - set when the Pro shell aggregates contacts
|
||||||
// from multiple connected accounts. See `Calendar.localAccountId`.
|
// from multiple connected accounts. See `Calendar.localAccountId`.
|
||||||
localAccountId?: string;
|
localAccountId?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -381,7 +381,7 @@ export interface AddressBook {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
// See `Calendar.localAccountId` — same purpose for address books.
|
// See `Calendar.localAccountId` - same purpose for address books.
|
||||||
localAccountId?: string;
|
localAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +483,7 @@ export interface CalendarEvent {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
// See `Calendar.localAccountId` — same purpose for events.
|
// See `Calendar.localAccountId` - same purpose for events.
|
||||||
localAccountId?: string;
|
localAccountId?: string;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isOrigin: boolean;
|
isOrigin: boolean;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
|||||||
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
|
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
|
||||||
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
* so blob-imported plugin code could resolve `react`. With the sandbox model
|
||||||
* plugins receive React injected as a function argument inside their iframe
|
* plugins receive React injected as a function argument inside their iframe
|
||||||
* runtime — there is nothing to expose on the host window.
|
* runtime - there is nothing to expose on the host window.
|
||||||
*
|
*
|
||||||
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
* Kept as a no-op for callers that still invoke it during app bootstrap.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// a bundle the loader verifies the signature; mismatch refuses the load.
|
// a bundle the loader verifies the signature; mismatch refuses the load.
|
||||||
//
|
//
|
||||||
// User-installed plugins (uploaded via the file picker, no server hop) have
|
// User-installed plugins (uploaded via the file picker, no server hop) have
|
||||||
// no signature — verification is skipped for those, since the user is
|
// no signature - verification is skipped for those, since the user is
|
||||||
// installing their own code. Verification kicks in for server-managed
|
// installing their own code. Verification kicks in for server-managed
|
||||||
// bundles only (the `managed: true` flag on `InstalledPlugin`).
|
// bundles only (the `managed: true` flag on `InstalledPlugin`).
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
'admin.getAllConfig': 'admin:config',
|
'admin.getAllConfig': 'admin:config',
|
||||||
'admin.setConfig': 'admin:config',
|
'admin.setConfig': 'admin:config',
|
||||||
'admin.deleteConfig': 'admin:config',
|
'admin.deleteConfig': 'admin:config',
|
||||||
// ui — any plugin can ask the host to render a modal or open a URL.
|
// ui - any plugin can ask the host to render a modal or open a URL.
|
||||||
'ui.confirm': null,
|
'ui.confirm': null,
|
||||||
'ui.alert': null,
|
'ui.alert': null,
|
||||||
'ui.openExternalUrl': null,
|
'ui.openExternalUrl': null,
|
||||||
@@ -289,7 +289,7 @@ export async function dispatchApiCall(
|
|||||||
}
|
}
|
||||||
case 'ui.openExternalUrl': {
|
case 'ui.openExternalUrl': {
|
||||||
const url = String(args[0] ?? '');
|
const url = String(args[0] ?? '');
|
||||||
// Only http(s) — the sandbox should not be able to navigate the host
|
// Only http(s) - the sandbox should not be able to navigate the host
|
||||||
// anywhere internal, nor open javascript:/data:/file: schemes.
|
// anywhere internal, nor open javascript:/data:/file: schemes.
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
|
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function encodeCallbacks(
|
|||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((v) => encodeCallbacks(v, table, depth + 1));
|
return value.map((v) => encodeCallbacks(v, table, depth + 1));
|
||||||
}
|
}
|
||||||
// Plain object — copy own enumerable keys.
|
// Plain object - copy own enumerable keys.
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
out[k] = encodeCallbacks(v, table, depth + 1);
|
out[k] = encodeCallbacks(v, table, depth + 1);
|
||||||
@@ -161,7 +161,7 @@ export class SandboxInstance {
|
|||||||
|
|
||||||
private send(msg: HostToSandbox): void {
|
private send(msg: HostToSandbox): void {
|
||||||
// targetOrigin '*' is required because the iframe is opaque-origin. The
|
// targetOrigin '*' is required because the iframe is opaque-origin. The
|
||||||
// payload contains no host secrets — bundle code and manifest fields the
|
// payload contains no host secrets - bundle code and manifest fields the
|
||||||
// plugin already owns.
|
// plugin already owns.
|
||||||
this.iframe.contentWindow?.postMessage(msg, '*');
|
this.iframe.contentWindow?.postMessage(msg, '*');
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ export class SandboxInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'slot-resize':
|
case 'slot-resize':
|
||||||
// The iframe has no intrinsic height — sync it to the content height
|
// The iframe has no intrinsic height - sync it to the content height
|
||||||
// the sandbox reported, otherwise the wrapper reserves space but the
|
// the sandbox reported, otherwise the wrapper reserves space but the
|
||||||
// iframe stays at 0px and the slot appears blank.
|
// iframe stays at 0px and the slot appears blank.
|
||||||
this.iframe.style.height = `${msg.height}px`;
|
this.iframe.style.height = `${msg.height}px`;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
/**
|
/**
|
||||||
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
|
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
|
||||||
* bundlers should be configured to externalise React; the runtime provides
|
* bundlers should be configured to externalise React; the runtime provides
|
||||||
* those modules here. Anything else is refused — the sandbox has no Node-
|
* those modules here. Anything else is refused - the sandbox has no Node-
|
||||||
* compatible module resolution and we don't want plugins probing globals.
|
* compatible module resolution and we don't want plugins probing globals.
|
||||||
*
|
*
|
||||||
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
|
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
|
||||||
@@ -337,7 +337,7 @@ function bootSlot(payload: SlotInit): void {
|
|||||||
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
|
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populated by bootSlot — receives `props-update` messages.
|
// Populated by bootSlot - receives `props-update` messages.
|
||||||
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
|
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
|
||||||
|
|
||||||
async function handleInit(payload: InitPayload): Promise<void> {
|
async function handleInit(payload: InitPayload): Promise<void> {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function authenticateWizardRequest(): Promise<boolean> {
|
|||||||
export function buildSessionCookieAttributes(request?: NextRequest) {
|
export function buildSessionCookieAttributes(request?: NextRequest) {
|
||||||
// Match Secure to the actual request protocol. Browsers drop Secure cookies
|
// Match Secure to the actual request protocol. Browsers drop Secure cookies
|
||||||
// on plain HTTP, so unconditionally setting Secure in production breaks
|
// on plain HTTP, so unconditionally setting Secure in production breaks
|
||||||
// setup over HTTP — the operator gets "Wizard session required" on every
|
// setup over HTTP - the operator gets "Wizard session required" on every
|
||||||
// step. The wizard surfaces a cleartext-credentials warning in the UI when
|
// step. The wizard surfaces a cleartext-credentials warning in the UI when
|
||||||
// HTTPS isn't in use.
|
// HTTPS isn't in use.
|
||||||
return {
|
return {
|
||||||
|
|||||||
+8
-8
@@ -57,7 +57,7 @@ function unfoldLines(vcf: string): string {
|
|||||||
.replace(/\n[ \t]/g, "");
|
.replace(/\n[ \t]/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 6868 parameter value encoding — used inside parameter values only.
|
// RFC 6868 parameter value encoding - used inside parameter values only.
|
||||||
// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE.
|
// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE.
|
||||||
function decodeParamValue(s: string): string {
|
function decodeParamValue(s: string): string {
|
||||||
let out = "";
|
let out = "";
|
||||||
@@ -301,7 +301,7 @@ export function parseVCard(vcfString: string): ContactCard[] {
|
|||||||
function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||||
const id = `import-${generateUUID()}`;
|
const id = `import-${generateUUID()}`;
|
||||||
const card: ContactCard = { id, addressBookIds: {} };
|
const card: ContactCard = { id, addressBookIds: {} };
|
||||||
// Deferred BIRTHPLACE/DEATHPLACE values — attach to anniversary at end,
|
// Deferred BIRTHPLACE/DEATHPLACE values - attach to anniversary at end,
|
||||||
// because the BDAY/DEATHDATE entry may appear in any order.
|
// because the BDAY/DEATHDATE entry may appear in any order.
|
||||||
let birthPlace: string | undefined;
|
let birthPlace: string | undefined;
|
||||||
let deathPlace: string | undefined;
|
let deathPlace: string | undefined;
|
||||||
@@ -465,7 +465,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
mediaType: mime,
|
mediaType: mime,
|
||||||
};
|
};
|
||||||
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
|
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
|
||||||
// vCard 4.0 URI value (data URI or URL) — no ENCODING param.
|
// vCard 4.0 URI value (data URI or URL) - no ENCODING param.
|
||||||
card.media[`m${idx}`] = {
|
card.media[`m${idx}`] = {
|
||||||
kind: "photo",
|
kind: "photo",
|
||||||
uri: val,
|
uri: val,
|
||||||
@@ -760,7 +760,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "ORG-DIRECTORY": {
|
case "ORG-DIRECTORY": {
|
||||||
// RFC 6715 §2.4 — directory URI for the contact's organization.
|
// RFC 6715 §2.4 - directory URI for the contact's organization.
|
||||||
if (!card.directories) card.directories = {};
|
if (!card.directories) card.directories = {};
|
||||||
const idx = Object.keys(card.directories).length;
|
const idx = Object.keys(card.directories).length;
|
||||||
card.directories[`d${idx}`] = {
|
card.directories[`d${idx}`] = {
|
||||||
@@ -789,14 +789,14 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "GRAMGENDER": {
|
case "GRAMGENDER": {
|
||||||
// RFC 9554 §3.4 — grammatical gender (animate/common/feminine/masculine/neuter).
|
// RFC 9554 §3.4 - grammatical gender (animate/common/feminine/masculine/neuter).
|
||||||
if (!card.speakToAs) card.speakToAs = {};
|
if (!card.speakToAs) card.speakToAs = {};
|
||||||
card.speakToAs.grammaticalGender = val.toLowerCase();
|
card.speakToAs.grammaticalGender = val.toLowerCase();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "PRONOUNS": {
|
case "PRONOUNS": {
|
||||||
// RFC 9554 §3.5 — free-form pronouns. May appear multiple times.
|
// RFC 9554 §3.5 - free-form pronouns. May appear multiple times.
|
||||||
if (!card.speakToAs) card.speakToAs = {};
|
if (!card.speakToAs) card.speakToAs = {};
|
||||||
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
|
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
|
||||||
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
|
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
|
||||||
@@ -1058,7 +1058,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contact.personalInfo) {
|
if (contact.personalInfo) {
|
||||||
// RFC 6715 — emit EXPERTISE / HOBBY / INTEREST with LEVEL.
|
// RFC 6715 - emit EXPERTISE / HOBBY / INTEREST with LEVEL.
|
||||||
const levelOut: Record<string, Record<string, string>> = {
|
const levelOut: Record<string, Record<string, string>> = {
|
||||||
expertise: { high: "expert", medium: "average", low: "beginner" },
|
expertise: { high: "expert", medium: "average", low: "beginner" },
|
||||||
hobby: { high: "high", medium: "medium", low: "low" },
|
hobby: { high: "high", medium: "medium", low: "low" },
|
||||||
@@ -1167,7 +1167,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contact.created) {
|
if (contact.created) {
|
||||||
// RFC 9554 §3.1 — CREATED is a timestamp; emit as-is for round-trip.
|
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
|
||||||
lines.push(`CREATED:${contact.created}`);
|
lines.push(`CREATED:${contact.created}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Lenient semver comparison for the marketplace's `minAppVersion` gate.
|
* Lenient semver comparison for the marketplace's `minAppVersion` gate.
|
||||||
*
|
*
|
||||||
* Parses "major.minor.patch" (any segment may be missing — treated as 0)
|
* Parses "major.minor.patch" (any segment may be missing - treated as 0)
|
||||||
* and ignores pre-release / build metadata. Returns negative, zero or
|
* and ignores pre-release / build metadata. Returns negative, zero or
|
||||||
* positive in the same shape as Array.prototype.sort comparators.
|
* positive in the same shape as Array.prototype.sort comparators.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1256,7 +1256,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orphan-cookie adoption — when no accounts are registered but a
|
// Orphan-cookie adoption - when no accounts are registered but a
|
||||||
// basic-auth session cookie is present (set by /api/auth/impersonate
|
// basic-auth session cookie is present (set by /api/auth/impersonate
|
||||||
// or by another server-side hand-off), promote it into the account
|
// or by another server-side hand-off), promote it into the account
|
||||||
// registry so the normal restoration path picks it up. Without this
|
// registry so the normal restoration path picks it up. Without this
|
||||||
@@ -1664,5 +1664,5 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Expose getClientForAccount to the calendar/contact stores via a small
|
// Expose getClientForAccount to the calendar/contact stores via a small
|
||||||
// shared registry — see [[stores/client-registry]] for rationale.
|
// shared registry - see [[stores/client-registry]] for rationale.
|
||||||
setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId));
|
setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId));
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { getClientByLocalAccountId } from './client-registry';
|
|||||||
/**
|
/**
|
||||||
* When the Pro shell aggregates calendars/events from every connected
|
* When the Pro shell aggregates calendars/events from every connected
|
||||||
* account, the entity carries a `localAccountId` pointing back to the
|
* account, the entity carries a `localAccountId` pointing back to the
|
||||||
* owning JMAP client. Mutations need to use *that* client — the active
|
* owning JMAP client. Mutations need to use *that* client - the active
|
||||||
* client (passed in by the page) could be on a different server entirely.
|
* client (passed in by the page) could be on a different server entirely.
|
||||||
* Falls back to the active client when `localAccountId` is unset or no
|
* Falls back to the active client when `localAccountId` is unset or no
|
||||||
* matching client is registered.
|
* matching client is registered.
|
||||||
@@ -75,7 +75,7 @@ function prefixCalendarsWithLocalAccount(
|
|||||||
return calendars.map((cal) => ({ ...cal, localAccountId }));
|
return calendars.map((cal) => ({ ...cal, localAccountId }));
|
||||||
}
|
}
|
||||||
const prefix = buildCrossAccountIdPrefix(localAccountId);
|
const prefix = buildCrossAccountIdPrefix(localAccountId);
|
||||||
// Preserve each calendar's original `isShared` flag — it distinguishes
|
// Preserve each calendar's original `isShared` flag - it distinguishes
|
||||||
// the user's own calendars on the other account from calendars shared
|
// the user's own calendars on the other account from calendars shared
|
||||||
// *into* that account by yet another user. The sidebar uses this split
|
// *into* that account by yet another user. The sidebar uses this split
|
||||||
// to render "My Calendars" vs "Shared" sub-sections per account.
|
// to render "My Calendars" vs "Shared" sub-sections per account.
|
||||||
@@ -186,7 +186,7 @@ export interface ICalSubscription {
|
|||||||
url: string;
|
url: string;
|
||||||
calendarId: string;
|
calendarId: string;
|
||||||
// The JMAP account this subscription belongs to. Optional for back-
|
// The JMAP account this subscription belongs to. Optional for back-
|
||||||
// compat with subs persisted before multi-account scoping landed —
|
// compat with subs persisted before multi-account scoping landed -
|
||||||
// legacy entries with no accountId are shown only in whichever account
|
// legacy entries with no accountId are shown only in whichever account
|
||||||
// the user has active (treated as floating). New subs always set it.
|
// the user has active (treated as floating). New subs always set it.
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -910,7 +910,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
if (calendarEvents.length === 0) break;
|
if (calendarEvents.length === 0) break;
|
||||||
|
|
||||||
// Separate events that live ONLY in this calendar (delete) from
|
// Separate events that live ONLY in this calendar (delete) from
|
||||||
// events also linked to other calendars (unlink only — don't
|
// events also linked to other calendars (unlink only - don't
|
||||||
// cascade-delete the user's copy elsewhere).
|
// cascade-delete the user's copy elsewhere).
|
||||||
const idsToDelete: string[] = [];
|
const idsToDelete: string[] = [];
|
||||||
const eventsToUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
|
const eventsToUnlink: Array<{ id: string; calendarIds: Record<string, boolean> }> = [];
|
||||||
@@ -1008,7 +1008,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
icalSubscriptions: [...state.icalSubscriptions, subscription],
|
icalSubscriptions: [...state.icalSubscriptions, subscription],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Initial fetch — roll back the calendar create if it fails so we
|
// Initial fetch - roll back the calendar create if it fails so we
|
||||||
// don't leave a phantom calendar around after a bad URL / 404 / etc.
|
// don't leave a phantom calendar around after a bad URL / 404 / etc.
|
||||||
await get().refreshICalSubscription(client, subscription.id);
|
await get().refreshICalSubscription(client, subscription.id);
|
||||||
|
|
||||||
@@ -1097,7 +1097,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
|
|
||||||
// Skip if the subscription is scoped to a different JMAP account
|
// Skip if the subscription is scoped to a different JMAP account
|
||||||
// than the one this client is talking to — otherwise we'd create
|
// than the one this client is talking to - otherwise we'd create
|
||||||
// events in the wrong account / against a missing calendar.
|
// events in the wrong account / against a missing calendar.
|
||||||
if (sub.accountId && sub.accountId !== client.getAccountId()) {
|
if (sub.accountId && sub.accountId !== client.getAccountId()) {
|
||||||
debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name });
|
debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name });
|
||||||
@@ -1228,7 +1228,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
clearState: () => {
|
clearState: () => {
|
||||||
// Preserve iCal subscriptions across the account-switch teardown.
|
// Preserve iCal subscriptions across the account-switch teardown.
|
||||||
// They're now scoped per-account via sub.accountId — wiping them
|
// They're now scoped per-account via sub.accountId - wiping them
|
||||||
// here would lose them from localStorage on every switch.
|
// here would lose them from localStorage on every switch.
|
||||||
const preservedSubs = get().icalSubscriptions;
|
const preservedSubs = get().icalSubscriptions;
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|||||||
/**
|
/**
|
||||||
* Tiny indirection used by the calendar and contact stores to look up a
|
* Tiny indirection used by the calendar and contact stores to look up a
|
||||||
* JMAP client by local account ID without importing `auth-store` directly
|
* JMAP client by local account ID without importing `auth-store` directly
|
||||||
* — that would form a top-level cycle (auth-store already imports the
|
* - that would form a top-level cycle (auth-store already imports the
|
||||||
* feature stores to bootstrap them after login).
|
* feature stores to bootstrap them after login).
|
||||||
*
|
*
|
||||||
* `auth-store` registers its `getClientForAccount` on module init via
|
* `auth-store` registers its `getClientForAccount` on module init via
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface EmailStore {
|
|||||||
accountMailboxes: Record<string, Mailbox[]>;
|
accountMailboxes: Record<string, Mailbox[]>;
|
||||||
/**
|
/**
|
||||||
* When set, the mail view is reading from this account instead of the
|
* When set, the mail view is reading from this account instead of the
|
||||||
* global active one. `null` means "use the global active account" — i.e.
|
* global active one. `null` means "use the global active account" - i.e.
|
||||||
* the standard single-account behavior. Selecting a folder under a
|
* the standard single-account behavior. Selecting a folder under a
|
||||||
* non-active account in the Pro sidebar updates this without changing
|
* non-active account in the Pro sidebar updates this without changing
|
||||||
* `useAuthStore.activeAccountId`.
|
* `useAuthStore.activeAccountId`.
|
||||||
@@ -249,7 +249,7 @@ function resolveActionMailboxes(): Mailbox[] {
|
|||||||
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
||||||
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
||||||
* fresh mailbox list so the helpers can resolve the role mailbox per account.
|
* fresh mailbox list so the helpers can resolve the role mailbox per account.
|
||||||
* Accounts whose mailbox fetch fails are skipped — the unified result will
|
* Accounts whose mailbox fetch fails are skipped - the unified result will
|
||||||
* surface that in its per-account error map.
|
* surface that in its per-account error map.
|
||||||
*/
|
*/
|
||||||
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
||||||
@@ -1162,7 +1162,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh mailbox folder lists/counters for every account we touched.
|
// Refresh mailbox folder lists/counters for every account we touched.
|
||||||
// Background-only so the move feels instant — counters will catch up.
|
// Background-only so the move feels instant - counters will catch up.
|
||||||
const activeAccountId = useAuthStore.getState().activeAccountId;
|
const activeAccountId = useAuthStore.getState().activeAccountId;
|
||||||
const touched = new Set<string>([destAccountId, ...emailIdsBySource.keys()]);
|
const touched = new Set<string>([destAccountId, ...emailIdsBySource.keys()]);
|
||||||
for (const acctId of touched) {
|
for (const acctId of touched) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ interface FileState {
|
|||||||
selectedResources: Set<string>;
|
selectedResources: Set<string>;
|
||||||
uploadProgress: UploadProgress | null;
|
uploadProgress: UploadProgress | null;
|
||||||
client: IJMAPClient | null;
|
client: IJMAPClient | null;
|
||||||
/** Which connected account's files are being browsed. Pro shell only — null in single-account contexts. */
|
/** Which connected account's files are being browsed. Pro shell only - null in single-account contexts. */
|
||||||
currentAccountId: string | null;
|
currentAccountId: string | null;
|
||||||
clipboard: ClipboardState | null;
|
clipboard: ClipboardState | null;
|
||||||
uploadAbortController: AbortController | null;
|
uploadAbortController: AbortController | null;
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// 'pending' or 'not-requested' — submit a request and refuse to enable.
|
// 'pending' or 'not-requested' - submit a request and refuse to enable.
|
||||||
await submitApprovalRequest(plugin).catch(() => { /* best effort */ });
|
await submitApprovalRequest(plugin).catch(() => { /* best effort */ });
|
||||||
set(state => ({
|
set(state => ({
|
||||||
plugins: state.plugins.map(p =>
|
plugins: state.plugins.map(p =>
|
||||||
@@ -165,12 +165,12 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (requireApproval && !policyApproved) {
|
} else if (requireApproval && !policyApproved) {
|
||||||
// No bundleHash means we can't pin the approval — refuse.
|
// No bundleHash means we can't pin the approval - refuse.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user consent gate: prompt for any permission the user has not
|
// Per-user consent gate: prompt for any permission the user has not
|
||||||
// explicitly approved yet. Managed plugins (admin-pushed) skip this —
|
// explicitly approved yet. Managed plugins (admin-pushed) skip this -
|
||||||
// the admin has already approved them at install time.
|
// the admin has already approved them at install time.
|
||||||
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
|
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
|
||||||
const granted = new Set<string>(plugin.grantedPermissions ?? []);
|
const granted = new Set<string>(plugin.grantedPermissions ?? []);
|
||||||
@@ -549,7 +549,7 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom
|
|||||||
// Ed25519 signature verification. Present on every server-managed bundle
|
// Ed25519 signature verification. Present on every server-managed bundle
|
||||||
// since the signing module is server-side; refuse to persist a bundle
|
// since the signing module is server-side; refuse to persist a bundle
|
||||||
// that fails verification. If the header is missing (older server / dev
|
// that fails verification. If the header is missing (older server / dev
|
||||||
// build with signing disabled) we log and allow — the SHA-256 hash check
|
// build with signing disabled) we log and allow - the SHA-256 hash check
|
||||||
// at load time still catches transport corruption.
|
// at load time still catches transport corruption.
|
||||||
const sig = res.headers.get('X-Bundle-Signature');
|
const sig = res.headers.get('X-Bundle-Signature');
|
||||||
if (sig) {
|
if (sig) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type ProTabKind =
|
|||||||
|
|
||||||
export type ProPaneId = 'main' | 'split';
|
export type ProPaneId = 'main' | 'split';
|
||||||
/**
|
/**
|
||||||
* Pro split layout. Only side-by-side is supported — the pane that "splits
|
* Pro split layout. Only side-by-side is supported - the pane that "splits
|
||||||
* off" always lives next to the main pane on the horizontal axis. Kept as
|
* off" always lives next to the main pane on the horizontal axis. Kept as
|
||||||
* a type alias to leave room for future layouts without churning callers.
|
* a type alias to leave room for future layouts without churning callers.
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ export type ProSplitOrientation = 'vertical';
|
|||||||
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirror of `EmailComposer.replyTo` — kept as a structural type here so the
|
* Mirror of `EmailComposer.replyTo` - kept as a structural type here so the
|
||||||
* tab store doesn't take a runtime dependency on the composer module.
|
* tab store doesn't take a runtime dependency on the composer module.
|
||||||
*/
|
*/
|
||||||
export interface ProReplyContext {
|
export interface ProReplyContext {
|
||||||
@@ -92,7 +92,7 @@ interface ProTabState {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Move a tab next to another tab. `edge` controls whether it lands before
|
* Move a tab next to another tab. `edge` controls whether it lands before
|
||||||
* or after the target — used by the tab bar's drop indicator. Reordering
|
* or after the target - used by the tab bar's drop indicator. Reordering
|
||||||
* works both within a pane and across panes (cross-pane drops move the
|
* works both within a pane and across panes (cross-pane drops move the
|
||||||
* tab to the target pane).
|
* tab to the target pane).
|
||||||
*/
|
*/
|
||||||
@@ -476,7 +476,7 @@ export const useProTabStore = create<ProTabState>()(
|
|||||||
{
|
{
|
||||||
name: 'pro-tabs',
|
name: 'pro-tabs',
|
||||||
version: 3,
|
version: 3,
|
||||||
// Don't persist transient compose drafts in tab metadata — the composer's
|
// Don't persist transient compose drafts in tab metadata - the composer's
|
||||||
// own draft-store already handles that. Persisted email tabs are fine to
|
// own draft-store already handles that. Persisted email tabs are fine to
|
||||||
// restore (the tab body refetches the email by id).
|
// restore (the tab body refetches the email by id).
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings that must never round-trip through the cross-device sync API.
|
* Settings that must never round-trip through the cross-device sync API.
|
||||||
* Decided per device and kept only in the local zustand-persist storage —
|
* Decided per device and kept only in the local zustand-persist storage -
|
||||||
* a value already stored on the server (from a prior build) is ignored on
|
* a value already stored on the server (from a prior build) is ignored on
|
||||||
* import.
|
* import.
|
||||||
*/
|
*/
|
||||||
@@ -520,7 +520,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
toolbarPosition: state.toolbarPosition,
|
toolbarPosition: state.toolbarPosition,
|
||||||
hideAccountSwitcher: state.hideAccountSwitcher,
|
hideAccountSwitcher: state.hideAccountSwitcher,
|
||||||
showRailAccountList: state.showRailAccountList,
|
showRailAccountList: state.showRailAccountList,
|
||||||
// proInterface is intentionally omitted — it's a per-device choice
|
// proInterface is intentionally omitted - it's a per-device choice
|
||||||
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
|
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
|
||||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
@@ -853,7 +853,7 @@ if (typeof window !== 'undefined') {
|
|||||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||||
syncEnabled = false;
|
syncEnabled = false;
|
||||||
} else if (res.status === 403) {
|
} else if (res.status === 403) {
|
||||||
// Identity mismatch — current session cookies don't match the
|
// Identity mismatch - current session cookies don't match the
|
||||||
// username/serverUrl we're syncing for (common in dev mock mode where
|
// username/serverUrl we're syncing for (common in dev mock mode where
|
||||||
// no stalwart-context cookie is written, or when rememberMe is off).
|
// no stalwart-context cookie is written, or when rememberMe is off).
|
||||||
// Retrying won't help for this session; disable to stop the noise.
|
// Retrying won't help for this session; disable to stop the noise.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
// Legacy storage key used by an earlier build that persisted unlock passphrases
|
// Legacy storage key used by an earlier build that persisted unlock passphrases
|
||||||
// in sessionStorage. Wipe on module load so any in-flight tab upgrading to this
|
// in sessionStorage. Wipe on module load so any in-flight tab upgrading to this
|
||||||
// version doesn't leave plaintext key material sitting around. New code never
|
// version doesn't leave plaintext key material sitting around. New code never
|
||||||
// writes here — unlocked CryptoKey handles live only in the in-memory Map below.
|
// writes here - unlocked CryptoKey handles live only in the in-memory Map below.
|
||||||
const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session';
|
const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session';
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ }
|
try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user