+ {/* Only for the window's controls: the sidebar says which page is open. */}
+
+ {pageName ?? 'Admin'}
+
{readOnlyBanner}
diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx
index a5051d4fb..2b5630630 100644
--- a/components/admin/org/__tests__/offline-section.test.tsx
+++ b/components/admin/org/__tests__/offline-section.test.tsx
@@ -59,6 +59,22 @@ describe('Offline mode section', () => {
expect((puts[0].organisation as { devices: { noOffline: boolean } }).devices.noOffline).toBe(true);
});
+ it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => {
+ await renderSection();
+ const toggle = screen.getByTestId('org-devices-wipeOnForcedSignOut');
+ expect(toggle.getAttribute('aria-checked')).toBe('false');
+ act(() => latest!.setScopeId('field'));
+ fireEvent.click(screen.getByTestId('org-devices-wipeOnForcedSignOut'));
+ expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnForcedSignOut: true } });
+ expect(latest!.policy?.organisation?.devices.wipeOnForcedSignOut).toBe(false);
+ });
+
+ it('says what a sign-out does now, for the timers too', async () => {
+ await renderSection();
+ expect(document.body.textContent).not.toContain('Every sign-out, the automatic one after inactivity included, deletes everything');
+ expect(document.body.textContent).toContain('A sign-out someone asks for deletes everything');
+ });
+
it('writes a scope override only into that scope', async () => {
await renderSection();
act(() => latest!.setScopeId('field'));
diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx
index 359648627..9a806ccbc 100644
--- a/components/admin/org/sections/offline-section.tsx
+++ b/components/admin/org/sections/offline-section.tsx
@@ -50,6 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
const forbid = draft.orgValue('devices', 'noOffline');
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
+ const wipeOnRefused = draft.orgValue('devices', 'wipeOnForcedSignOut');
return (
draft.clearOrgValue('devices', 'noOfflineAttachments')} />
+
+
+ draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" />
+ draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />
+
+
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to
- them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one
- after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings.
+ them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything.
+ One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
Bulwark Lite reads the same policy but has no offline mode.{' '}
How offline mode works
diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx
new file mode 100644
index 000000000..6316958fa
--- /dev/null
+++ b/components/app-lock/__tests__/lock-screen.test.tsx
@@ -0,0 +1,217 @@
+import 'fake-indexeddb/auto';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AppLockSettings } from '@/components/settings/app-lock-settings';
+import { LockScreen } from '../lock-screen';
+import { createVault, deleteVault, FREE_FAILURES } from '@/lib/app-lock/vault';
+import { writeAppLockFile } from '@/lib/app-lock/state';
+
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
+
+const unlockWithPin = vi.hoisted(() => vi.fn());
+const forgetEverything = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/app-lock/controller', async (original) => ({
+ ...(await original()),
+ unlockWithPin,
+ forgetEverything,
+}));
+
+function setPolicy(required: boolean, minutes: number | null, locked: boolean): void {
+ (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
+ version: 1,
+ platform: 'win32',
+ policy: {
+ get: () => ({
+ AppLockRequired: { value: required, locked: required, source: required ? 'forced-machine' : 'default' },
+ AppLockTimeoutMinutes: { value: minutes, locked, source: locked ? 'forced-machine' : 'default' },
+ }),
+ onChange: () => () => {},
+ },
+ };
+}
+
+beforeEach(async () => {
+ await deleteVault();
+ localStorage.clear();
+ unlockWithPin.mockReset();
+ forgetEverything.mockReset();
+});
+
+afterEach(() => {
+ delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+});
+
+function typeAndSubmit(pin: string): void {
+ fireEvent.change(screen.getByTestId('app-lock-pin'), { target: { value: pin } });
+ fireEvent.submit(screen.getByTestId('app-lock-form'));
+}
+
+describe('the lock screen', () => {
+ it('passes the PIN on and shows nothing but the lock', async () => {
+ (await createVault('correct-pin')).fill(0);
+ unlockWithPin.mockResolvedValue(undefined);
+ render();
+ typeAndSubmit('correct-pin');
+ await waitFor(() => expect(unlockWithPin).toHaveBeenCalledWith('correct-pin'));
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-app-lock-overlay');
+ });
+
+ it('says a wrong PIN was wrong, and empties the field', async () => {
+ (await createVault('correct-pin')).fill(0);
+ unlockWithPin.mockRejectedValue(Object.assign(new Error('x'), { name: 'WrongPinError' }));
+ render();
+ typeAndSubmit('nope-nope');
+ await waitFor(() => expect(screen.getByTestId('app-lock-message')).toHaveTextContent('wrong_pin'));
+ expect(screen.getByTestId('app-lock-pin')).toHaveValue('');
+ });
+
+ it('comes up blocked after a reload, from what is on disk', async () => {
+ (await createVault('correct-pin')).fill(0);
+ const { openVaultWithPin } = await import('@/lib/app-lock/vault');
+ for (let i = 0; i <= FREE_FAILURES; i += 1) await openVaultWithPin('wrong-pin').catch(() => {});
+ await act(async () => {
+ render();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+ await waitFor(() => expect(screen.getByTestId('app-lock-pin')).toBeDisabled());
+ expect(screen.getByTestId('app-lock-message').textContent).toMatch(/blocked_/);
+ expect(screen.getByTestId('app-lock-unlock')).toBeDisabled();
+ });
+
+ it('offers no passkey or OS button when none is set up, and always the way out', async () => {
+ (await createVault('correct-pin')).fill(0);
+ render();
+ await waitFor(() => expect(screen.getByTestId('app-lock-forgot')).toBeInTheDocument());
+ expect(screen.queryByTestId('app-lock-passkey')).toBeNull();
+ expect(screen.queryByTestId('app-lock-os')).toBeNull();
+ });
+
+ it('asks about a forgotten PIN inside the lock screen: everything on beside it is hidden', async () => {
+ (await createVault('correct-pin')).fill(0);
+ render();
+ fireEvent.click(await screen.findByTestId('app-lock-forgot'));
+ const confirm = screen.getByRole('button', { name: 'forgot_confirm' });
+ expect(confirm.closest('[data-app-lock-overlay]')).not.toBeNull();
+ expect(screen.getByRole('dialog')).toHaveTextContent('forgot_body');
+ fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
+ expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument();
+ expect(forgetEverything).not.toHaveBeenCalled();
+ });
+
+ it('forgets only after the yes, and comes back when main says no', async () => {
+ (await createVault('correct-pin')).fill(0);
+ forgetEverything.mockResolvedValue(false);
+ render();
+ fireEvent.click(await screen.findByTestId('app-lock-forgot'));
+ fireEvent.click(screen.getByRole('button', { name: 'forgot_confirm' }));
+ await waitFor(() => expect(forgetEverything).toHaveBeenCalledTimes(1));
+ expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument();
+ });
+
+ describe('in a pop-out', () => {
+ class Channel {
+ static open = new Set();
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ constructor() { Channel.open.add(this); }
+ postMessage(data: unknown): void {
+ for (const channel of Channel.open) if (channel !== this) channel.onmessage?.({ data } as MessageEvent);
+ }
+ close(): void { Channel.open.delete(this); }
+ }
+
+ beforeEach(() => {
+ Channel.open.clear();
+ vi.stubGlobal('BroadcastChannel', Channel);
+ window.history.replaceState(null, '', '/popout/compose');
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ window.history.replaceState(null, '', '/');
+ });
+
+ it('sends people to the main window, and asks for the PIN itself once that has closed', async () => {
+ (await createVault('correct-pin')).fill(0);
+ const main = new Channel();
+ let mainOpen = true;
+ main.onmessage = (event) => {
+ if (mainOpen && (event.data as { type: string }).type === 'main-ping') main.postMessage({ type: 'main-pong' });
+ };
+ render();
+ expect(await screen.findByRole('button', { name: 'open_main' })).toBeInTheDocument();
+ mainOpen = false;
+ main.close();
+ expect(await screen.findByTestId('app-lock-pin', {}, { timeout: 6000 })).toBeInTheDocument();
+ }, 15_000);
+ });
+
+ it('says it\'s saving rather than ask for anything while it locks', () => {
+ render();
+ expect(screen.queryByTestId('app-lock-pin')).toBeNull();
+ expect(screen.getByRole('dialog')).toHaveTextContent('locking');
+ });
+
+ it('asks for a PIN when an organisation requires it, and only a good one', () => {
+ render();
+ expect(screen.getByRole('dialog')).toHaveTextContent('setup_required_title');
+ const save = screen.getByRole('button', { name: 'save' });
+ expect(save).toBeDisabled();
+ fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '12345' } });
+ fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '12345' } });
+ expect(save).toBeDisabled();
+ fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '482913' } });
+ expect(save).toBeDisabled();
+ fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '482913' } });
+ expect(save).toBeEnabled();
+ });
+});
+
+describe('the settings', () => {
+ it('say what a PIN protects against here', () => {
+ render();
+ expect(screen.getByText('protects_desc')).toBeInTheDocument();
+ });
+
+ it('won\'t save a PIN anyone would guess', async () => {
+ render();
+ fireEvent.click(screen.getByTestId('app-lock-switch'));
+ fireEvent.change(await screen.findByTestId('app-lock-new-pin'), { target: { value: '123456' } });
+ fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } });
+ expect(screen.getByText('pin_obvious')).toBeInTheDocument();
+ expect(screen.getByTestId('app-lock-dialog-save')).toBeDisabled();
+ });
+
+ it('are off by default, with nothing else shown', () => {
+ render();
+ expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false');
+ expect(screen.queryByTestId('app-lock-now')).toBeNull();
+ expect(screen.queryByTestId('app-lock-timeout')).toBeNull();
+ });
+
+ it('show the switch as set by the organisation when a policy requires it', () => {
+ setPolicy(true, null, false);
+ writeAppLockFile({ on: true, locked: false });
+ const { container } = render();
+ const toggle = screen.getByTestId('app-lock-switch');
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ expect(toggle.closest('[inert]')).not.toBeNull();
+ expect(container.textContent).toContain('org_locked');
+ });
+
+ it('show a forced timeout as set by the organisation, with its value', () => {
+ setPolicy(false, 3, true);
+ writeAppLockFile({ on: true, locked: false, timeoutMinutes: 60 });
+ render();
+ const select = screen.getByTestId('app-lock-timeout');
+ expect(select).toHaveValue('3');
+ expect(select.closest('[inert]')).not.toBeNull();
+ });
+
+ it('leave the timeout to the user otherwise', () => {
+ writeAppLockFile({ on: true, locked: false, timeoutMinutes: 30 });
+ render();
+ const select = screen.getByTestId('app-lock-timeout');
+ expect(select).toHaveValue('30');
+ expect(select.closest('[inert]')).toBeNull();
+ });
+});
diff --git a/components/app-lock/app-lock-gate.tsx b/components/app-lock/app-lock-gate.tsx
new file mode 100644
index 000000000..13d2c9559
--- /dev/null
+++ b/components/app-lock/app-lock-gate.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Suspense, use, useEffect, useState, useSyncExternalStore } from "react";
+import { createPortal } from "react-dom";
+import { LockScreen } from "./lock-screen";
+import { appLockHold, bootAppLock, getAppLockPhase, recheckAppLockSetup, startAppLock, subscribeAppLockPhase, takeDraftsSavedNotice, type AppLockPhase } from "@/lib/app-lock/controller";
+import { IS_LITE } from "@/lib/lite";
+import { useAccountStore } from "@/stores/account-store";
+import { toast } from "@/stores/toast-store";
+import { useTranslations } from "next-intl";
+
+const serverPhase = (): AppLockPhase => "open";
+
+/**
+ * Suspends the app below while the lock holds. In the first render of a page that is
+ * locked, that keeps the server's markup inert: nothing below hydrates, so no effect runs,
+ * no store fills and no plugin frame starts, until someone unlocks in this same document.
+ * The server's markup is the empty skeleton, never mail.
+ */
+function Hold() {
+ if (typeof window === "undefined") return null;
+ bootAppLock();
+ const hold = appLockHold();
+ if (hold) use(hold);
+ return null;
+}
+
+/** Above everything that touches mail, and in every window: main, pop-outs, composers. */
+export function AppLockGate({ children }: { children: React.ReactNode }) {
+ const phase = useSyncExternalStore(subscribeAppLockPhase, getAppLockPhase, serverPhase);
+ const t = useTranslations("appLock");
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ return startAppLock();
+ }, []);
+
+ // Signing in while an organisation requires the lock: it's due now.
+ useEffect(() => {
+ if (IS_LITE) return;
+ recheckAppLockSetup();
+ return useAccountStore.subscribe(recheckAppLockSetup);
+ }, []);
+
+ useEffect(() => {
+ if (phase === "open" && takeDraftsSavedNotice()) toast.info(t("draft_saved_notice"));
+ }, [phase, t]);
+
+ if (IS_LITE) return <>{children}>;
+
+ return (
+ <>
+
+
+ {phase === "locked-stay" ? null : children}
+
+ {mounted && phase !== "open" && phase !== "checking" && createPortal(, document.body)}
+ >
+ );
+}
diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx
new file mode 100644
index 000000000..b18523ef1
--- /dev/null
+++ b/components/app-lock/lock-screen.tsx
@@ -0,0 +1,289 @@
+"use client";
+
+import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
+import { useTranslations } from "next-intl";
+import { Lock } from "@/components/icons";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useConfig } from "@/hooks/use-config";
+import { NewPinFields, newPinReady } from "./pin-fields";
+import { enableAppLock, forgetEverything, unlockWithOs, unlockWithPasskey, unlockWithPin, type AppLockPhase } from "@/lib/app-lock/controller";
+import { passkeysPossible } from "@/lib/app-lock/passkey";
+import { APP_LOCK_CHANNEL, postAppLockMessage, type AppLockMessage } from "@/lib/app-lock/state";
+import { pinBlockedUntil, readVault } from "@/lib/app-lock/vault";
+import { getDesktopBridge } from "@/lib/desktop/bridge";
+import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
+import { isPopoutWindow } from "@/lib/popout/window-role";
+import { productName } from "@/lib/product-name";
+
+/** How often a pop-out asks whether a main window is there, and how long a silence means it's gone. */
+const MAIN_PING_MS = 1_000;
+const MAIN_GONE_MS = 2_500;
+
+function secondsLeft(until: number): number {
+ return Math.max(0, Math.ceil((until - Date.now()) / 1000));
+}
+
+function useBlockedUntil(): [number, (until: number) => void] {
+ const [until, setUntil] = useState(0);
+ const [, tick] = useState(0);
+ useEffect(() => {
+ void pinBlockedUntil().then(setUntil).catch(() => {});
+ }, []);
+ useEffect(() => {
+ if (until <= Date.now()) return;
+ const timer = setInterval(() => tick((n) => n + 1), 1000);
+ return () => clearInterval(timer);
+ }, [until]);
+ return [until > Date.now() ? until : 0, setUntil];
+}
+
+/**
+ * Whether a main window is there. The tab that unlocks keeps the key for as long as it lives,
+ * and a pop-out is about to close: so a pop-out sends people to the main window. It keeps
+ * asking, because a main window that closes or crashes says nothing, and with none left the
+ * pop-out has to take the PIN itself or nobody can get in.
+ */
+function useMainWindowThere(): boolean {
+ const [there, setThere] = useState(false);
+ useEffect(() => {
+ if (!isPopoutWindow() || typeof BroadcastChannel === "undefined") return;
+ const channel = new BroadcastChannel(APP_LOCK_CHANNEL);
+ let heardAt = 0;
+ channel.onmessage = (event: MessageEvent) => {
+ if (event.data.type !== "main-pong") return;
+ heardAt = Date.now();
+ setThere(true);
+ };
+ postAppLockMessage({ type: "main-ping" });
+ const timer = setInterval(() => {
+ if (Date.now() - heardAt > MAIN_GONE_MS) setThere(false);
+ postAppLockMessage({ type: "main-ping" });
+ }, MAIN_PING_MS);
+ return () => {
+ clearInterval(timer);
+ channel.close();
+ };
+ }, []);
+ return there;
+}
+
+function Frame({ title, body, children }: { title: string; body?: string; children: React.ReactNode }) {
+ const titleId = useId();
+ return (
+
+
+
+
+
+
+
{title}
+ {body &&
{body}
}
+
+ {children}
+
+
+ );
+}
+
+function SetupForm() {
+ const t = useTranslations("appLock");
+ const appName = productName(useConfig().appName);
+ const [pin, setPin] = useState("");
+ const [repeat, setRepeat] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [failed, setFailed] = useState(false);
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!newPinReady(pin, repeat) || busy) return;
+ setBusy(true);
+ setFailed(false);
+ try {
+ await enableAppLock(pin);
+ } catch {
+ setFailed(true);
+ setBusy(false);
+ }
+ };
+ return (
+
+
+
+ );
+}
+
+function UnlockForm() {
+ const t = useTranslations("appLock");
+ const appName = productName(useConfig().appName);
+ const [forgetting, setForgetting] = useState(false);
+ const [pin, setPin] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [message, setMessage] = useState(null);
+ const [blockedUntil, setBlockedUntil] = useBlockedUntil();
+ const [hasPasskey, setHasPasskey] = useState(false);
+ const [osKind, setOsKind] = useState(null);
+ const mainThere = useMainWindowThere();
+ const inputRef = useRef(null);
+ const pinId = useId();
+
+ useEffect(() => {
+ void readVault().then((vault) => setHasPasskey(Boolean(vault?.prf) && passkeysPossible())).catch(() => {});
+ void getDesktopBridge()?.appLock?.status().then((status) => setOsKind(status.osEnrolled ? status.os : null)).catch(() => {});
+ }, []);
+
+ const blocked = blockedUntil > 0;
+ const left = secondsLeft(blockedUntil);
+
+ const submit = useCallback(async (event: FormEvent) => {
+ event.preventDefault();
+ if (!pin || busy || blocked) return;
+ setBusy(true);
+ setMessage(null);
+ try {
+ await unlockWithPin(pin);
+ } catch (error) {
+ const name = (error as Error)?.name;
+ setBusy(false);
+ setPin("");
+ inputRef.current?.focus();
+ if (name === "PinBlockedError") setBlockedUntil((error as Error & { until: number }).until);
+ else if (name === "WrongPinError") {
+ setMessage(t("wrong_pin"));
+ void pinBlockedUntil().then(setBlockedUntil).catch(() => {});
+ } else setMessage(t("unlock_failed"));
+ }
+ }, [blocked, busy, pin, setBlockedUntil, t]);
+
+ const withOs = async () => {
+ setBusy(true);
+ setMessage(null);
+ try {
+ if (await unlockWithOs()) return;
+ } catch {
+ // The PIN below still works.
+ }
+ setBusy(false);
+ setMessage(t("os_failed"));
+ };
+
+ const withPasskey = async () => {
+ setBusy(true);
+ setMessage(null);
+ try {
+ await unlockWithPasskey();
+ } catch {
+ setBusy(false);
+ setMessage(t("passkey_failed"));
+ }
+ };
+
+ // Here and not in a dialog: a dialog is a portal on , which the lock keeps hidden.
+ if (forgetting) {
+ return (
+
+ {t("forgot_provider")}
+
+
+
+
+
+ );
+ }
+
+ if (mainThere) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+/** What covers a window while the app is locked. Reads nothing but the lock's own records. */
+export function LockScreen({ phase }: { phase: AppLockPhase }) {
+ const t = useTranslations("appLock");
+ const appName = productName(useConfig().appName);
+ if (phase === "setup") return ;
+ if (phase === "locking") {
+ return (
+
+
+
+ );
+ }
+ return ;
+}
diff --git a/components/app-lock/pin-fields.tsx b/components/app-lock/pin-fields.tsx
new file mode 100644
index 000000000..8e55a831f
--- /dev/null
+++ b/components/app-lock/pin-fields.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useId } from "react";
+import { useTranslations } from "next-intl";
+import { Input } from "@/components/ui/input";
+import { pinIsObvious, pinLongEnough } from "@/lib/app-lock/crypto";
+
+/** The new PIN, twice. Says what's wrong only once there's something to say. */
+export function NewPinFields({ pin, repeat, onPin, onRepeat }: {
+ pin: string;
+ repeat: string;
+ onPin: (value: string) => void;
+ onRepeat: (value: string) => void;
+}) {
+ const t = useTranslations("appLock");
+ const pinId = useId();
+ const repeatId = useId();
+ const hintId = useId();
+ const tooShort = pin.length > 0 && !pinLongEnough(pin);
+ const obvious = pinLongEnough(pin) && pinIsObvious(pin);
+ const mismatch = repeat.length > 0 && repeat !== pin;
+ return (
+
+
+
+
onPin(event.target.value)}
+ aria-describedby={hintId}
+ aria-invalid={tooShort || obvious || undefined}
+ />
+
{t("pin_hint")}
+ {obvious &&
{t("pin_obvious")}
}
+
+
+
+
onRepeat(event.target.value)}
+ aria-invalid={mismatch || undefined}
+ />
+ {mismatch &&
{t("pin_mismatch")}
}
+
+
+ );
+}
+
+export function newPinReady(pin: string, repeat: string): boolean {
+ return pinLongEnough(pin) && !pinIsObvious(pin) && pin === repeat;
+}
+
+/** A PIN already set, asked again before something that needs it. */
+export function CurrentPinField({ value, onChange, error }: { value: string; onChange: (value: string) => void; error?: string | null }) {
+ const t = useTranslations("appLock");
+ const id = useId();
+ return (
+
+
+
onChange(event.target.value)}
+ aria-invalid={error ? true : undefined}
+ />
+ {error &&
{error}
}
+
+ );
+}
diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx
index 16bc29d97..0db2ff49b 100644
--- a/components/boot-skeleton/admin-skeleton.tsx
+++ b/components/boot-skeleton/admin-skeleton.tsx
@@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
+
+
+
diff --git a/components/boot-skeleton/calendar-skeleton.tsx b/components/boot-skeleton/calendar-skeleton.tsx
index 28191a02c..87011e717 100644
--- a/components/boot-skeleton/calendar-skeleton.tsx
+++ b/components/boot-skeleton/calendar-skeleton.tsx
@@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const views = text?.views;
return (
<>
-
+
diff --git a/components/boot-skeleton/files-skeleton.tsx b/components/boot-skeleton/files-skeleton.tsx
index 840ec325e..6802ccd98 100644
--- a/components/boot-skeleton/files-skeleton.tsx
+++ b/components/boot-skeleton/files-skeleton.tsx
@@ -199,7 +199,7 @@ function FilesSidebarSkeleton() {
function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
const on = "bg-primary/10 text-primary";
return (
-
+
{picker ? null :
}
diff --git a/components/boot-skeleton/login-skeleton.tsx b/components/boot-skeleton/login-skeleton.tsx
index c36e4ef8d..28141c548 100644
--- a/components/boot-skeleton/login-skeleton.tsx
+++ b/components/boot-skeleton/login-skeleton.tsx
@@ -65,7 +65,7 @@ export function LoginSkeleton({ label, accessibilityButton = false, className }:
)}
>
{/* The theme menu button: its icon, and the theme's name from sm up. */}
-
+
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index 063d040ba..a13165166 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -411,7 +411,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
const button = "h-8 w-20 rounded-md";
return (
-
+
{/* Phones: back, then icon-over-label actions. */}
diff --git a/components/boot-skeleton/pro-skeleton.tsx b/components/boot-skeleton/pro-skeleton.tsx
index 3e6eecda0..6065e72d5 100644
--- a/components/boot-skeleton/pro-skeleton.tsx
+++ b/components/boot-skeleton/pro-skeleton.tsx
@@ -34,6 +34,8 @@ function TabStripSkeleton() {
return (
diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx
index 7d8c868ec..f9d73e546 100644
--- a/components/boot-skeleton/settings-skeleton.tsx
+++ b/components/boot-skeleton/settings-skeleton.tsx
@@ -163,7 +163,7 @@ function SettingsPage({ sections }: { sections: number }) {
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
function PhoneHeader({ list }: { list: boolean }) {
return (
-
+
@@ -270,6 +270,11 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean
+ {embedded ? null : (
+
+
+
+ )}
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx
index 75fb07763..40c372390 100644
--- a/components/calendar/calendar-toolbar.tsx
+++ b/components/calendar/calendar-toolbar.tsx
@@ -225,7 +225,7 @@ export function CalendarToolbar({
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
-
+
{/* Row 1 while searching: the search box with a way back. */}
{mobileSearchField ? (
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 57fad15a2..94438b553 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -1037,7 +1037,7 @@ export function EventModal({
{/* Header */}
-
+
diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx
new file mode 100644
index 000000000..2b4ed8af3
--- /dev/null
+++ b/components/calendar/missed-reminders-dialog.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useLocale, useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Dialog } from "@/components/ui/dialog";
+import { getPathPrefix } from "@/lib/browser-navigation";
+import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state";
+import { dismissReminders, joinLabel, joinMeeting } from "@/lib/desktop/reminder-notifications";
+import { useSettingsStore } from "@/stores/settings-store";
+
+/** What came due while the app was closed, the machine slept or notifications were paused. */
+export function MissedRemindersDialog() {
+ const t = useTranslations("desktopReminders");
+ const tAlert = useTranslations("calendar.notifications");
+ const locale = useLocale();
+ const timeZone = useSettingsStore((s) => s.timeZone);
+ const account = useMissedReminders((s) => s.account);
+ const alerts = useMissedReminders((s) => s.alerts);
+ const close = useMissedReminders((s) => s.close);
+ const joinLabels = { join: t("join"), joinHost: (host: string) => t("join_host", { host }) };
+
+ if (!account || alerts.length === 0) return null;
+
+ const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) });
+ const finish = () => {
+ dismissReminders(account, alerts.map((alert) => alert.key));
+ close();
+ };
+
+ return (
+
+ );
+}
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx
index a23cfd610..75ea6439a 100644
--- a/components/calendar/task-modal.tsx
+++ b/components/calendar/task-modal.tsx
@@ -222,7 +222,7 @@ export function TaskModal({
onKeyDown={handleKeyDown}
>
{/* Header */}
-
+
{isEdit ? t("tasks.edit") : t("tasks.create")}
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index 2b0b382d3..2f6b44f0d 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -99,7 +99,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
if (!contact) {
return (
-
+
+ {/* The window's controls lie over this end, and its empty top is something to drag by. */}
+
{t("detail.no_contact_selected")}
@@ -139,7 +141,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
return (
-
+
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx
index d35baf1af..dbd29960a 100644
--- a/components/contacts/contact-form.tsx
+++ b/components/contacts/contact-form.tsx
@@ -704,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
// The app checks the fields itself and says what is wrong inline; the
// browser's own bubble covered that message.