mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
Merge branch 'feat/do-app-lock' into feat/desktop-offline
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AppLockGate } from "@/components/app-lock/app-lock-gate";
|
||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||
@@ -79,6 +80,7 @@ export default async function LocaleLayout({
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<AppLockGate>
|
||||
<ThemeProvider>
|
||||
<CalendarAlertProvider>
|
||||
<RateLimitToastProvider>
|
||||
@@ -122,7 +124,6 @@ export default async function LocaleLayout({
|
||||
</MainWindowOnly>
|
||||
{/* Offline mode isn't in Lite (no service worker there). */}
|
||||
{!IS_LITE && <OfflineHost />}
|
||||
{!IS_LITE && <DeviceWipeHost />}
|
||||
{!IS_LITE && <NotificationActionsHost />}
|
||||
<DevicePolicyHost />
|
||||
<MainWindowOnly>
|
||||
@@ -136,6 +137,9 @@ export default async function LocaleLayout({
|
||||
</RateLimitToastProvider>
|
||||
</CalendarAlertProvider>
|
||||
</ThemeProvider>
|
||||
</AppLockGate>
|
||||
{/* Beside the gate, not under it: a stolen device at the lock screen still has to hear a wipe. */}
|
||||
{!IS_LITE && <DeviceWipeHost />}
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { freshLoginRequired } from "@/lib/app-lock/sign-out";
|
||||
import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
|
||||
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
|
||||
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
@@ -678,6 +679,7 @@ function LoginPageContent() {
|
||||
locale: params.locale,
|
||||
server_id: selectedServer?.id,
|
||||
remember: persistProviderSession,
|
||||
...(freshLoginRequired() ? { fresh_login: true } : {}),
|
||||
...(isMobileHandoff
|
||||
? {
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
@@ -931,6 +933,7 @@ function LoginPageContent() {
|
||||
if (isAddAccountMode) {
|
||||
authUrl.searchParams.set("prompt", "select_account");
|
||||
}
|
||||
if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login");
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
};
|
||||
@@ -1029,6 +1032,7 @@ function LoginPageContent() {
|
||||
if (isAddAccountMode) {
|
||||
authUrl.searchParams.set("prompt", "select_account");
|
||||
}
|
||||
if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login");
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ import "../globals.css";
|
||||
import "../print.css";
|
||||
import "../desktop.css";
|
||||
import { desktopBootScript } from "@/lib/desktop/boot";
|
||||
import { appLockBootScript } from "@/lib/app-lock/state";
|
||||
import "../app-lock.css";
|
||||
|
||||
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||
// next-intl's getLocale() returns the default locale here - emitting
|
||||
@@ -312,6 +314,7 @@ export default async function RootLayout({
|
||||
__html: `
|
||||
(function() {${IS_LITE ? liteLocaleBootstrap() : ""}
|
||||
${desktopBootScript()}
|
||||
${IS_LITE ? "" : appLockBootScript()}
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
purpose: rawPurpose,
|
||||
slot: rawSlot,
|
||||
remember,
|
||||
fresh_login: freshLogin,
|
||||
} = await request.json();
|
||||
|
||||
// `reauth` drives the step-up flow for device pairing: it forces a fresh
|
||||
@@ -178,7 +179,9 @@ export async function POST(request: NextRequest) {
|
||||
// depends on the IdP supporting this OIDC param. Deliberately no max_age=0:
|
||||
// some IdPs (Authelia) re-evaluate max_age at the consent step and loop
|
||||
// back to login forever, and nothing on our side reads auth_time.
|
||||
if (isReauth) {
|
||||
// The same after a forgotten app lock PIN: the provider's session outlived it, and
|
||||
// someone at the device shouldn't walk back in through it (lib/app-lock/sign-out.ts).
|
||||
if (isReauth || freshLogin === true) {
|
||||
authUrl.searchParams.set('prompt', 'login');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* While the app lock is on and it isn't known yet to be unlocked, nothing under the
|
||||
* lock screen shows, from the first frame. The inline script in app/(main)/layout.tsx
|
||||
* sets the attribute before the first paint, and lib/app-lock/controller.ts takes it away.
|
||||
* The window's drag strip stays: it shows the app's name and nothing else.
|
||||
*/
|
||||
html[data-app-locked] body > *:not([data-app-lock-overlay]):not(.window-title-bar) {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
@@ -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<typeof import('@/lib/app-lock/controller')>()),
|
||||
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(<LockScreen phase="locked" />);
|
||||
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(<LockScreen phase="locked" />);
|
||||
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(<LockScreen phase="locked" />);
|
||||
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(<LockScreen phase="locked" />);
|
||||
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 <body> beside it is hidden', async () => {
|
||||
(await createVault('correct-pin')).fill(0);
|
||||
render(<LockScreen phase="locked" />);
|
||||
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(<LockScreen phase="locked" />);
|
||||
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<Channel>();
|
||||
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(<LockScreen phase="locked" />);
|
||||
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(<LockScreen phase="locking" />);
|
||||
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(<LockScreen phase="setup" />);
|
||||
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(<AppLockSettings />);
|
||||
expect(screen.getByText('protects_desc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('won\'t save a PIN anyone would guess', async () => {
|
||||
render(<AppLockSettings />);
|
||||
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(<AppLockSettings />);
|
||||
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(<AppLockSettings />);
|
||||
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(<AppLockSettings />);
|
||||
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(<AppLockSettings />);
|
||||
const select = screen.getByTestId('app-lock-timeout');
|
||||
expect(select).toHaveValue('30');
|
||||
expect(select.closest('[inert]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<Hold />
|
||||
{phase === "locked-stay" ? null : children}
|
||||
</Suspense>
|
||||
{mounted && phase !== "open" && phase !== "checking" && createPortal(<LockScreen phase={phase} />, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<AppLockMessage>) => {
|
||||
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 (
|
||||
<div
|
||||
data-app-lock-overlay=""
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="fixed inset-0 z-[100001] flex items-center justify-center overflow-y-auto bg-background p-6 pt-[calc(var(--titlebar-inset,0px)+1.5rem)]"
|
||||
>
|
||||
<div className="w-full max-w-sm space-y-5">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-foreground">
|
||||
<Lock className="h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
<h1 id={titleId} className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
{body && <p className="text-sm text-muted-foreground">{body}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Frame title={t("setup_required_title")} body={t("setup_required_body", { app: appName })}>
|
||||
<form onSubmit={submit} className="space-y-4" data-testid="app-lock-setup">
|
||||
<NewPinFields pin={pin} repeat={repeat} onPin={setPin} onRepeat={setRepeat} />
|
||||
{failed && <p role="alert" className="text-sm text-destructive">{t("save_failed")}</p>}
|
||||
<Button type="submit" className="w-full" disabled={busy || !newPinReady(pin, repeat)}>{t("save")}</Button>
|
||||
</form>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [blockedUntil, setBlockedUntil] = useBlockedUntil();
|
||||
const [hasPasskey, setHasPasskey] = useState(false);
|
||||
const [osKind, setOsKind] = useState<DesktopOsUnlockKind | null>(null);
|
||||
const mainThere = useMainWindowThere();
|
||||
const inputRef = useRef<HTMLInputElement>(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 <body>, which the lock keeps hidden.
|
||||
if (forgetting) {
|
||||
return (
|
||||
<Frame title={t("forgot_title")} body={t("forgot_body")}>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("forgot_provider")}</p>
|
||||
<div className="flex flex-col gap-2" data-testid="app-lock-forget">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
void forgetEverything().catch(() => false).then((gone) => {
|
||||
if (gone) return;
|
||||
setBusy(false);
|
||||
setForgetting(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("forgot_confirm")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" disabled={busy} onClick={() => setForgetting(false)}>{t("cancel")}</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
if (mainThere) {
|
||||
return (
|
||||
<Frame title={t("lock_title", { app: appName })} body={t("unlock_in_main")}>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const windows = getDesktopBridge()?.windows;
|
||||
if (windows?.focus) windows.focus();
|
||||
else postAppLockMessage({ type: "focus-main" });
|
||||
}}
|
||||
>
|
||||
{t("open_main")}
|
||||
</Button>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame title={t("lock_title", { app: appName })} body={t("pin_prompt")}>
|
||||
<form onSubmit={submit} className="space-y-3" data-testid="app-lock-form">
|
||||
<label htmlFor={pinId} className="sr-only">{t("pin_label")}</label>
|
||||
<Input
|
||||
id={pinId}
|
||||
ref={inputRef}
|
||||
data-testid="app-lock-pin"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={pin}
|
||||
onChange={(event) => setPin(event.target.value)}
|
||||
placeholder={t("pin_label")}
|
||||
disabled={blocked}
|
||||
/>
|
||||
<div role="status" aria-live="polite" className="min-h-5 text-sm text-destructive" data-testid="app-lock-message">
|
||||
{blocked
|
||||
? (left >= 60 ? t("blocked_minutes", { count: Math.ceil(left / 60) }) : t("blocked_seconds", { count: left }))
|
||||
: message}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={!pin || busy || blocked} data-testid="app-lock-unlock">
|
||||
{busy ? t("unlocking") : t("unlock")}
|
||||
</Button>
|
||||
{osKind && (
|
||||
<Button type="button" variant="outline" className="w-full" disabled={busy} onClick={withOs} data-testid="app-lock-os">
|
||||
{osKind === "hello" ? t("use_hello") : t("use_touchid")}
|
||||
</Button>
|
||||
)}
|
||||
{hasPasskey && (
|
||||
<Button type="button" variant="outline" className="w-full" disabled={busy} onClick={withPasskey} data-testid="app-lock-passkey">
|
||||
{t("use_passkey")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" className="w-full" onClick={() => setForgetting(true)} data-testid="app-lock-forgot">
|
||||
{t("forgot")}
|
||||
</Button>
|
||||
</form>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <SetupForm />;
|
||||
if (phase === "locking") {
|
||||
return (
|
||||
<Frame title={t("lock_title", { app: appName })} body={t("locking")}>
|
||||
<span />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
return <UnlockForm />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={pinId} className="text-sm font-medium text-foreground">{t("pin_new")}</label>
|
||||
<Input
|
||||
id={pinId}
|
||||
data-testid="app-lock-new-pin"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={pin}
|
||||
onChange={(event) => onPin(event.target.value)}
|
||||
aria-describedby={hintId}
|
||||
aria-invalid={tooShort || obvious || undefined}
|
||||
/>
|
||||
<p id={hintId} className="text-xs text-muted-foreground">{t("pin_hint")}</p>
|
||||
{obvious && <p role="alert" className="text-xs text-destructive">{t("pin_obvious")}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={repeatId} className="text-sm font-medium text-foreground">{t("pin_repeat")}</label>
|
||||
<Input
|
||||
id={repeatId}
|
||||
data-testid="app-lock-repeat-pin"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={repeat}
|
||||
onChange={(event) => onRepeat(event.target.value)}
|
||||
aria-invalid={mismatch || undefined}
|
||||
/>
|
||||
{mismatch && <p role="alert" className="text-xs text-destructive">{t("pin_mismatch")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="text-sm font-medium text-foreground">{t("pin_current")}</label>
|
||||
<Input
|
||||
id={id}
|
||||
data-testid="app-lock-current-pin"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
aria-invalid={error ? true : undefined}
|
||||
/>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { isAppLockedNow } from "@/lib/app-lock/state";
|
||||
import { AppMain, SkipLink } from "@/components/layout/app-landmarks";
|
||||
import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
@@ -231,7 +232,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// Leaving the page or going Back asks too.
|
||||
useEffect(() => {
|
||||
if (!editorDirty) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
// The app lock asks nobody: the page is going to the lock screen.
|
||||
if (!isAppLockedNow()) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [editorDirty]);
|
||||
|
||||
@@ -168,7 +168,8 @@ import type { Editor } from "@tiptap/react";
|
||||
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import { isAppLockedNow } from "@/lib/app-lock/state";
|
||||
import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { noteComposerLanguage } from "@/lib/desktop/languages";
|
||||
import { noteOpenDraft } from "@/lib/desktop/window-restore";
|
||||
@@ -1960,6 +1961,8 @@ export function EmailComposer({
|
||||
ownWindowRef.current = ownWindow;
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
// The lock saved what it could and is on its way to the lock screen: no prompt, and no copy of the text.
|
||||
if (isAppLockedNow()) return;
|
||||
const own = ownWindowRef.current;
|
||||
if (!own) {
|
||||
if (isDirtyRef.current) {
|
||||
@@ -3150,6 +3153,15 @@ export function EmailComposer({
|
||||
if (saveFailedRef.current) throw new Error('The draft was not saved');
|
||||
}), []);
|
||||
|
||||
// Where this composer's text is, for the app lock.
|
||||
useEffect(() => registerComposeSnapshot({
|
||||
ownWindow: !!ownWindowRef.current,
|
||||
state: () => (sentRef.current || uneditedRef.current ? null : snapshotStateRef.current()),
|
||||
draft: () => (draftIdRef.current && !sentRef.current
|
||||
? { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }
|
||||
: null),
|
||||
}), []);
|
||||
|
||||
// The draft as plugins see it: onDraftChange, the frames above and below
|
||||
// the body, and clicks on plugin entries in the Send menu.
|
||||
const draftSnapshot = () => ({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { setLockedHere } from '@/lib/app-lock/state';
|
||||
import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { takeRescuedDraft } from '@/lib/popout/rescued-drafts';
|
||||
import { createSessionOpener } from '@/lib/popout/sealed';
|
||||
import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
@@ -121,6 +122,78 @@ describe('PopoutHost', () => {
|
||||
expect(JSON.stringify(heard)).not.toContain('Bearer main');
|
||||
});
|
||||
|
||||
it('gives no credentials while the app is locked, to a pop-out that never heard of the lock', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
setLockedHere(true);
|
||||
const opener = await createSessionOpener();
|
||||
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey });
|
||||
await settle();
|
||||
expect(answers()).toEqual([]);
|
||||
setLockedHere(false);
|
||||
popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey });
|
||||
await vi.waitFor(() => expect(answers()).toHaveLength(1));
|
||||
expect(answers().map((message) => (message as { requestId: string }).requestId)).toEqual(['r2']);
|
||||
});
|
||||
|
||||
it('renews no token for a pop-out while the app is locked', async () => {
|
||||
const refresh = vi.fn(async () => 'next');
|
||||
useAuthStore.setState({ refreshAccessToken: refresh as never });
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
setLockedHere(true);
|
||||
try {
|
||||
const opener = await createSessionOpener();
|
||||
const rejectedFingerprint = await credentialFingerprint('Bearer main');
|
||||
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint });
|
||||
await settle(40);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(answers()).toEqual([]);
|
||||
} finally {
|
||||
setLockedHere(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers nothing when the lock comes during the renewal', async () => {
|
||||
const refresh = vi.fn(async () => {
|
||||
setLockedHere(true);
|
||||
return 'next';
|
||||
});
|
||||
useAuthStore.setState({ refreshAccessToken: refresh as never });
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
try {
|
||||
const opener = await createSessionOpener();
|
||||
const rejectedFingerprint = await credentialFingerprint('Bearer main');
|
||||
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint });
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
await settle(40);
|
||||
expect(answers()).toEqual([]);
|
||||
} finally {
|
||||
setLockedHere(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('a locked pop-out doesn\'t ask for a session', async () => {
|
||||
setLockedHere(true);
|
||||
await expect(requestWindowSession({}, 100)).resolves.toBeNull();
|
||||
setLockedHere(false);
|
||||
expect(heard.filter((message) => message.type === 'session-request')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a locked pop-out takes no session from the cookies either, and asks nobody', async () => {
|
||||
const fetched = vi.fn(async () => new Response('{}'));
|
||||
vi.stubGlobal('fetch', fetched);
|
||||
setLockedHere(true);
|
||||
try {
|
||||
await expect(useAuthStore.getState().startSharedSession({ accountId: 'alice' })).resolves.toBe('no-session');
|
||||
} finally {
|
||||
setLockedHere(false);
|
||||
}
|
||||
expect(fetched).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().client).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false });
|
||||
render(<PopoutHost />);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { isAppLockedNow } from "@/lib/app-lock/state";
|
||||
import { usePopoutsAvailable } from "@/hooks/use-popouts-available";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -76,6 +77,8 @@ export function PopoutHost() {
|
||||
|
||||
const answerSessionRequest = async (message: Extract<PopoutMessage, { type: "session-request" }>) => {
|
||||
const { accountId, rejectedFingerprint } = message;
|
||||
// Locked: nobody gets a credential, whatever they ask.
|
||||
if (isAppLockedNow()) return;
|
||||
const auth = useAuthStore.getState();
|
||||
// Only a window holding the login answers: the first answer has to be usable.
|
||||
if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return;
|
||||
@@ -86,7 +89,7 @@ export function PopoutHost() {
|
||||
if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId });
|
||||
}
|
||||
const sessions = shareableSessions();
|
||||
if (sessions.length === 0) return;
|
||||
if (sessions.length === 0 || isAppLockedNow()) return;
|
||||
const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions });
|
||||
channel?.post({ type: "session-answer", requestId: message.requestId, sealed });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CurrentPinField, NewPinFields, newPinReady } from "@/components/app-lock/pin-fields";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import {
|
||||
addOsUnlock,
|
||||
addPasskey,
|
||||
changeAppLockPin,
|
||||
disableAppLock,
|
||||
enableAppLock,
|
||||
hasPasskey,
|
||||
lockEverywhere,
|
||||
removePasskey,
|
||||
setAppLockTimeout,
|
||||
} from "@/lib/app-lock/controller";
|
||||
import { passkeysPossible } from "@/lib/app-lock/passkey";
|
||||
import { appLockRequired, effectiveTimeout, onLockPolicyChange } from "@/lib/app-lock/policy";
|
||||
import { readAppLockFile, TIMEOUT_CHOICES } from "@/lib/app-lock/state";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
|
||||
type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null;
|
||||
|
||||
function useLockFacts() {
|
||||
const [, refresh] = useState(0);
|
||||
const update = useCallback(() => refresh((n) => n + 1), []);
|
||||
useEffect(() => onLockPolicyChange(update), [update]);
|
||||
const file = readAppLockFile();
|
||||
return { on: file.on, required: appLockRequired(), timeout: effectiveTimeout(file.timeoutMinutes), update };
|
||||
}
|
||||
|
||||
/** The app lock's section on the General page: it has to exist on any server. */
|
||||
export function AppLockSettings() {
|
||||
const t = useTranslations("appLock");
|
||||
const { appName } = useConfig();
|
||||
const { on, required, timeout, update } = useLockFacts();
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [pin, setPin] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [passkey, setPasskey] = useState(false);
|
||||
const [os, setOs] = useState<{ kind: DesktopOsUnlockKind; enrolled: boolean } | null>(null);
|
||||
|
||||
const refreshMethods = useCallback(() => {
|
||||
void hasPasskey().then(setPasskey).catch(() => {});
|
||||
void getDesktopBridge()?.appLock?.status()
|
||||
.then((status) => setOs(status.os ? { kind: status.os, enrolled: status.osEnrolled } : null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(refreshMethods, [refreshMethods, on]);
|
||||
|
||||
const close = () => {
|
||||
setPending(null);
|
||||
setPin("");
|
||||
setRepeat("");
|
||||
setCurrent("");
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const describeFailure = (failure: unknown): string => {
|
||||
const name = (failure as Error)?.name;
|
||||
if (name === "WrongPinError") return t("wrong_pin");
|
||||
if (name === "PinBlockedError") return t("blocked_later");
|
||||
if (name === "PasskeyNoPrfError") return t("passkey_no_prf");
|
||||
if (name === "ObviousPinError") return t("pin_obvious");
|
||||
return t("save_failed");
|
||||
};
|
||||
|
||||
const run = async (work: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
close();
|
||||
update();
|
||||
refreshMethods();
|
||||
} catch (failure) {
|
||||
setBusy(false);
|
||||
setError(describeFailure(failure));
|
||||
}
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin));
|
||||
if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin));
|
||||
if (pending === "disable" && current) void run(() => disableAppLock(current));
|
||||
if (pending === "passkey" && current) {
|
||||
void run(async () => {
|
||||
await addPasskey(current, appName);
|
||||
toast.success(t("passkey_added"));
|
||||
});
|
||||
}
|
||||
if (pending === "os" && current) {
|
||||
void run(async () => {
|
||||
if (!(await addOsUnlock(current))) throw new Error("Not set up");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (IS_LITE) return null;
|
||||
|
||||
const osName = os?.kind === "touchid" ? t("use_touchid_name") : t("use_hello_name");
|
||||
const asksNew = pending === "enable" || pending === "change";
|
||||
const asksCurrent = pending === "disable" || pending === "change" || pending === "passkey" || pending === "os";
|
||||
const ready = (!asksNew || newPinReady(pin, repeat)) && (!asksCurrent || current.length > 0);
|
||||
const titles: Record<Exclude<Pending, null>, string> = {
|
||||
enable: t("set_pin_title"),
|
||||
change: t("change_pin_title"),
|
||||
disable: t("disable_title"),
|
||||
passkey: t("passkey_add_title"),
|
||||
os: t("os_add_title", { method: osName }),
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="app-lock" title={t("section_title")} description={t("section_desc")}>
|
||||
<SettingItem label={t("enable_label")} description={t("enable_desc")} locked={required}>
|
||||
<ToggleSwitch checked={on || required} onChange={(next) => setPending(next ? "enable" : "disable")} testId="app-lock-switch" />
|
||||
</SettingItem>
|
||||
<p className="text-xs text-muted-foreground" data-testid="app-lock-protects">{t("protects_desc")}</p>
|
||||
|
||||
{on && (
|
||||
<>
|
||||
<SettingItem label={t("timeout_label")} description={t("timeout_desc")} locked={timeout.locked}>
|
||||
<select
|
||||
data-testid="app-lock-timeout"
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
|
||||
value={timeout.minutes}
|
||||
onChange={(event) => {
|
||||
setAppLockTimeout(Number(event.target.value));
|
||||
update();
|
||||
}}
|
||||
>
|
||||
{[...new Set([...TIMEOUT_CHOICES, timeout.minutes])].sort((a, b) => a - b).map((minutes) => (
|
||||
<option key={minutes} value={minutes}>{t("timeout_option", { count: minutes })}</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t("lock_now")} description={t("lock_now_desc")}>
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={lockEverywhere} data-testid="app-lock-now">{t("lock_now")}</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t("pin_label")} description={t("pin_recovery")}>
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => setPending("change")}>{t("change_pin")}</Button>
|
||||
</SettingItem>
|
||||
|
||||
{passkeysPossible() && (
|
||||
<SettingItem label={t("passkey_label")} description={t("passkey_desc")}>
|
||||
{passkey ? (
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => void removePasskey().then(refreshMethods)}>{t("passkey_remove")}</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => setPending("passkey")} data-testid="app-lock-add-passkey">{t("passkey_add")}</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{os && (
|
||||
<SettingItem label={osName} description={t("os_desc", { method: osName })}>
|
||||
{os.enrolled ? (
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => void getDesktopBridge()?.appLock?.osRemove().then(refreshMethods)}>{t("os_remove")}</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => setPending("os")}>{t("os_add")}</Button>
|
||||
)}
|
||||
</SettingItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={pending !== null}
|
||||
onClose={close}
|
||||
title={pending ? titles[pending] : ""}
|
||||
description={pending === "disable" ? t("disable_body") : pending === "enable" ? t("enable_body") : undefined}
|
||||
onSubmit={submit}
|
||||
dirty={pin.length > 0 || current.length > 0}
|
||||
testId="app-lock-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={close}>{t("cancel")}</Button>
|
||||
<Button type="submit" disabled={busy || !ready} data-testid="app-lock-dialog-save">
|
||||
{pending === "disable" ? t("disable_confirm") : t("save")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{asksCurrent && <CurrentPinField value={current} onChange={setCurrent} error={error} />}
|
||||
{asksNew && <NewPinFields pin={pin} repeat={repeat} onPin={setPin} onRepeat={setRepeat} />}
|
||||
{!asksCurrent && error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</Dialog>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { LanguageSettings } from './language-settings';
|
||||
import { AppLockSettings } from './app-lock-settings';
|
||||
import { DesktopKeystoreSettings } from './desktop-keystore-settings';
|
||||
import { DesktopTelemetrySettings } from './desktop-telemetry-settings';
|
||||
import { ProtocolHandlerSettings } from './protocol-handler-settings';
|
||||
@@ -28,6 +29,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
|
||||
<div className="space-y-8">
|
||||
<LanguageSettings />
|
||||
|
||||
<AppLockSettings />
|
||||
|
||||
<DesktopOsSettings />
|
||||
|
||||
{!IS_LITE && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
|
||||
@@ -66,6 +66,21 @@ describe('useSidebarApps CSP cookie', () => {
|
||||
expect(sessionStorage.getItem('bulwark:pending-inline-app')).toContain('app-1');
|
||||
});
|
||||
|
||||
it('with the app lock on, opens beside the app instead: a reload would ask for the PIN', () => {
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: false }));
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
try {
|
||||
useSettingsStore.setState({ sidebarApps: [app()] });
|
||||
const { result } = renderHook(() => useSidebarApps());
|
||||
act(() => result.current.handleInlineApp('app-1', 'https://board.example.com/x', 'Board'));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledWith('https://board.example.com/x', '_blank', 'noopener,noreferrer');
|
||||
} finally {
|
||||
open.mockRestore();
|
||||
localStorage.removeItem('bulwark-app-lock');
|
||||
}
|
||||
});
|
||||
|
||||
it('reopens the pending app after that reload without reloading again', () => {
|
||||
useSettingsStore.setState({ sidebarApps: [app()] });
|
||||
const { result: first } = renderHook(() => useSidebarApps());
|
||||
|
||||
@@ -21,6 +21,7 @@ import { playNotificationSound } from '@/lib/notification-sound';
|
||||
import { lastingToastDuration } from '@/lib/toast-duration';
|
||||
import { startTitleAlert } from '@/lib/title-alert';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { goInApp } from '@/lib/plugin-sandbox/page-navigation';
|
||||
import { isAppLocked } from '@/lib/desktop/app-lock-state';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
|
||||
@@ -180,7 +181,7 @@ export function useCalendarAlerts() {
|
||||
message,
|
||||
duration: reminderToastDuration(),
|
||||
onClick: () => {
|
||||
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||
goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
|
||||
},
|
||||
onDismiss: () => {
|
||||
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
|
||||
@@ -221,7 +222,7 @@ export function useCalendarAlerts() {
|
||||
message: taskMsg,
|
||||
duration: reminderToastDuration(),
|
||||
onClick: () => {
|
||||
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||
goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
|
||||
},
|
||||
onDismiss: () => {
|
||||
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAppLockOn } from '@/lib/app-lock/state';
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps';
|
||||
@@ -97,11 +98,19 @@ export function useSidebarApps() {
|
||||
const origins = inlineAppFrameOrigins(sidebarApps);
|
||||
if (!origins.includes(origin)) origins.push(origin);
|
||||
if (writeAppFrameOrigins(origins)) {
|
||||
// Only a new document gets the new CSP, and with the app lock on that would ask for
|
||||
// the PIN just to open an app. So this once it opens beside the app, and in place
|
||||
// from the next start on.
|
||||
if (isAppLockOn()) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_APP_KEY, JSON.stringify(app));
|
||||
} catch {
|
||||
// Private mode / storage full - the app just won't reopen itself.
|
||||
}
|
||||
// full-load: a document's frame-src can't change without a new document.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createReminderRunner } from '@/lib/calendar-reminders/runner';
|
||||
import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
|
||||
import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification';
|
||||
import { isAppLocked } from '@/lib/desktop/app-lock-state';
|
||||
import { goInApp } from '@/lib/plugin-sandbox/page-navigation';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { contentHiddenFor } from '@/lib/desktop/mail-notifications';
|
||||
import {
|
||||
@@ -117,7 +118,7 @@ export function useStoreReminders(): void {
|
||||
message: current.when(alert),
|
||||
duration: reminderToastDuration(),
|
||||
onClick: () => {
|
||||
window.location.href = calendarUrlRef.current;
|
||||
goInApp('/calendar', calendarUrlRef.current);
|
||||
},
|
||||
onDismiss: () => {
|
||||
toastsByKey.delete(alert.key);
|
||||
@@ -139,7 +140,7 @@ export function useStoreReminders(): void {
|
||||
});
|
||||
},
|
||||
}] : []),
|
||||
{ label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } },
|
||||
{ label: current.open, onClick: () => { goInApp('/calendar', calendarUrlRef.current); } },
|
||||
],
|
||||
} : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
||||
import { ENT, ENT_PASSWORD, contextOptions, deliver, jmapAs, requireEntEnv, shot } from './helpers/ent';
|
||||
import { accountSwitcher } from './helpers/app';
|
||||
|
||||
/**
|
||||
* App lock (D8), through the real UI on a production build, on both test Stalwarts with
|
||||
* the throwaway ent-* accounts:
|
||||
* - off by default; on with a PIN in Settings > General;
|
||||
* - "Lock now": a lock screen, no mail in the page, its memory gone, the store's worker
|
||||
* and key gone, no plugin frames, no subjects in browser storage;
|
||||
* - a wrong PIN, the wait after too many that a reload doesn't reset;
|
||||
* - every tab locks and unlocks, one key: only the tab that unlocked holds it;
|
||||
* - an open composer's text is on the server as a draft, and not readable meanwhile;
|
||||
* - time without use across tabs;
|
||||
* - a new tab, a pop-out address and a fresh start stay locked;
|
||||
* - a passkey with PRF through a virtual authenticator, and one without, which keeps the PIN;
|
||||
* - forgetting the PIN removes the data and signs out, also offline and with a server that hangs;
|
||||
* - offline with a shell, a lock still goes through a reload, and memory with it;
|
||||
* - a device at the lock screen hears a remote wipe.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const PIN = 'it-pin-4827';
|
||||
const lockForm = (page: Page) => page.getByTestId('app-lock-form');
|
||||
const SECRET_SUBJECT = `applock-secret-${RUN}`;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
requireEntEnv();
|
||||
await deliver({ from: ENT.bob, to: [ENT.alice], subject: SECRET_SUBJECT, text: `Body of ${SECRET_SUBJECT}` });
|
||||
});
|
||||
|
||||
type Go = (page: Page, path: string) => Promise<unknown>;
|
||||
|
||||
const hardGoto: Go = (page, path) => page.goto(path);
|
||||
const softGoto: Go = (page, path) => page.evaluate((target) => (window as unknown as { next: { router: { push(path: string): void } } }).next.router.push(target), path);
|
||||
|
||||
async function enableOffline(page: Page, go = hardGoto): Promise<void> {
|
||||
await go(page, '/settings/backup/offline');
|
||||
const toggle = page.getByTestId('offline-switch');
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click();
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
// The store is open once its key record is there. A full first sync isn't what's tested.
|
||||
await expect.poll(async () => (await keyRecords(page).catch(() => [])).length, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function enableLock(page: Page, pin = PIN, go = hardGoto): Promise<void> {
|
||||
await go(page, '/settings/general');
|
||||
const toggle = page.getByTestId('app-lock-switch');
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
await toggle.click();
|
||||
await page.getByTestId('app-lock-new-pin').fill(pin);
|
||||
await page.getByTestId('app-lock-repeat-pin').fill(pin);
|
||||
await page.getByTestId('app-lock-dialog-save').click();
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'true', { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function lockNow(page: Page): Promise<void> {
|
||||
if (!page.url().includes('/settings/general')) await gotoUnlocked(page, '/settings/general');
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function unlock(page: Page, pin = PIN): Promise<void> {
|
||||
await page.getByTestId('app-lock-pin').fill(pin);
|
||||
await page.getByTestId('app-lock-unlock').click();
|
||||
await expect(lockForm(page)).toBeHidden({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* A full page load in the tab that holds the key loses the key, so it asks again. That's
|
||||
* the design: the key isn't kept anywhere that a reload could find it.
|
||||
*/
|
||||
async function gotoUnlocked(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await unlock(page);
|
||||
}
|
||||
|
||||
async function toMail(page: Page): Promise<void> {
|
||||
await gotoUnlocked(page, '/mail');
|
||||
await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function heldLocks(page: Page): Promise<string[]> {
|
||||
return page.evaluate(async () => ((await navigator.locks.query()).held ?? []).map((lock) => lock.name ?? ''));
|
||||
}
|
||||
|
||||
/** Everything a person or a script could read in plain browser storage. */
|
||||
async function browserStorage(page: Page): Promise<string> {
|
||||
return page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }));
|
||||
}
|
||||
|
||||
/** The account key records as they are on disk. */
|
||||
async function keyRecords(page: Page): Promise<Array<{ account: string; layers: string[] }>> {
|
||||
return page.evaluate(async () => new Promise(async (resolve, reject) => {
|
||||
// Opening one that doesn't exist would create it, empty, and break the app's own open.
|
||||
if (!(await indexedDB.databases()).some((db) => db.name === 'bulwark-store')) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const open = indexedDB.open('bulwark-store');
|
||||
open.onerror = () => reject(open.error);
|
||||
open.onsuccess = () => {
|
||||
if (!open.result.objectStoreNames.contains('accounts')) {
|
||||
open.result.close();
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const all = open.result.transaction('accounts').objectStore('accounts').getAll();
|
||||
all.onsuccess = () => {
|
||||
open.result.close();
|
||||
resolve((all.result as Array<{ account: string; layers: string[] }>).map(({ account, layers }) => ({ account, layers })));
|
||||
};
|
||||
all.onerror = () => reject(all.error);
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async function serverDraftBodies(subject: string): Promise<string[]> {
|
||||
const client = await jmapAs(ENT.alice);
|
||||
const r = await client.request([
|
||||
['Email/query', { accountId: client.accountId, filter: { subject }, limit: 10 }, '0'],
|
||||
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject', 'keywords', 'bodyValues', 'textBody'], fetchTextBodyValues: true }, '1'],
|
||||
]);
|
||||
const list = r.methodResponses[1][1].list as Array<{ keywords: Record<string, boolean>; bodyValues: Record<string, { value: string }> }>;
|
||||
return list.filter((email) => email.keywords.$draft).map((email) => Object.values(email.bodyValues).map((part) => part.value).join('\n'));
|
||||
}
|
||||
|
||||
/** On this rig the box isn't offered, and a login is remembered, so a reload finds the session again. */
|
||||
async function signIn(page: Page): Promise<void> {
|
||||
await page.fill('#username', ENT.alice);
|
||||
await page.fill('#password', ENT_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function fresh(browser: Browser): Promise<{ page: Page; context: BrowserContext }> {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await signIn(page);
|
||||
return { page, context };
|
||||
}
|
||||
|
||||
test('off by default: nothing changes and no record is wrapped', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await page.goto('/settings/general');
|
||||
await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 });
|
||||
expect(await page.evaluate(() => document.documentElement.hasAttribute('data-app-locked'))).toBe(false);
|
||||
expect(await heldLocks(page)).not.toContain('bulwark-app-lock-holder');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('locked: no mail in the page, its memory gone, the store closed with no key, and the PIN opens it again', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
|
||||
const records = await keyRecords(page);
|
||||
expect(records.length).toBeGreaterThan(0);
|
||||
for (const record of records) expect(record.layers).toEqual(['applock']);
|
||||
|
||||
await toMail(page);
|
||||
await expect(page.getByText(SECRET_SUBJECT).first()).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByText(SECRET_SUBJECT).first().click();
|
||||
await expect(page.getByRole('heading', { name: SECRET_SUBJECT }).first()).toBeVisible({ timeout: 20_000 });
|
||||
expect(await heldLocks(page)).toContain('bulwark-store-owner');
|
||||
expect(await heldLocks(page)).toContain('bulwark-app-lock-holder');
|
||||
|
||||
await gotoUnlocked(page, '/settings/general');
|
||||
await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; });
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before)).toBeUndefined();
|
||||
await expect(page.locator('body')).not.toContainText(SECRET_SUBJECT);
|
||||
expect(await page.content()).not.toContain(SECRET_SUBJECT);
|
||||
expect(await browserStorage(page)).not.toContain(SECRET_SUBJECT);
|
||||
expect(await page.title()).not.toContain(SECRET_SUBJECT);
|
||||
expect(await page.locator('iframe').count(), 'no frames, plugin frames included').toBe(0);
|
||||
const held = await heldLocks(page);
|
||||
expect(held, 'no worker holds the store').not.toContain('bulwark-store-owner');
|
||||
expect(held, 'nobody holds the key').not.toContain('bulwark-app-lock-holder');
|
||||
// What the app below the gate would start: from the very start of a locked page, nothing asks for mail.
|
||||
const requests: string[] = [];
|
||||
page.on('request', (request) => { if (/\/jmap|\/api\/auth\/(session|token)/.test(request.url())) requests.push(request.url()); });
|
||||
await page.reload();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
expect(requests).toEqual([]);
|
||||
await shot(page, 'app-lock-screen');
|
||||
|
||||
await unlock(page);
|
||||
await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 });
|
||||
expect(await heldLocks(page)).toContain('bulwark-app-lock-holder');
|
||||
await expect.poll(() => heldLocks(page), { timeout: 30_000 }).toContain('bulwark-store-owner');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a wrong PIN says so, and after too many the wait survives a reload', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableLock(page);
|
||||
await lockNow(page);
|
||||
const message = page.getByTestId('app-lock-message');
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await expect(page.getByTestId('app-lock-pin')).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByTestId('app-lock-pin').fill(`wrong-pin-${attempt}`);
|
||||
await page.getByTestId('app-lock-unlock').click();
|
||||
await expect(page.getByTestId('app-lock-pin')).toHaveValue('', { timeout: 30_000 });
|
||||
}
|
||||
await expect(message).toContainText('Too many tries', { timeout: 15_000 });
|
||||
await expect(page.getByTestId('app-lock-pin')).toBeDisabled();
|
||||
|
||||
await page.reload();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('app-lock-message')).toContainText('Too many tries', { timeout: 15_000 });
|
||||
await expect(page.getByTestId('app-lock-pin')).toBeDisabled();
|
||||
|
||||
await expect(page.getByTestId('app-lock-pin')).toBeEnabled({ timeout: 40_000 });
|
||||
await unlock(page);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('all tabs lock together, and a new tab or pop-out address stays locked; the one that unlocks holds the key', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
const second = await context.newPage();
|
||||
await second.goto('/mail');
|
||||
await expect(second.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// This tab holds the key: no new load here, or the others would lock for that.
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(lockForm(second)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(second.locator('body')).not.toContainText(SECRET_SUBJECT);
|
||||
|
||||
const third = await context.newPage();
|
||||
await third.goto('/mail');
|
||||
await expect(lockForm(third)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// A pop-out address: the lock screen sends people to the main window.
|
||||
const popout = await context.newPage();
|
||||
await popout.goto('/popout/compose');
|
||||
await expect(popout.getByRole('dialog').first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(popout.locator('[data-testid="email-composer"]')).toHaveCount(0);
|
||||
await expect(popout.getByRole('button', { name: 'Go to the main window' })).toBeVisible({ timeout: 10_000 });
|
||||
await popout.close();
|
||||
|
||||
await unlock(second);
|
||||
await expect(lockForm(page)).toBeHidden({ timeout: 30_000 });
|
||||
await expect(lockForm(third)).toBeHidden({ timeout: 30_000 });
|
||||
await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 });
|
||||
await expect(third.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// The key was never passed on: when the tab that has it goes, the others have to ask.
|
||||
// Up to a store call's wait for the owner that went, then the reload.
|
||||
await second.close();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(lockForm(third)).toBeVisible({ timeout: 90_000 });
|
||||
await unlock(page);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('an open composer\'s text is saved first, is unreadable while locked, and nothing prompts', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
const subject = `applock-draft-${RUN}`;
|
||||
const words = `words only the draft has ${RUN}`;
|
||||
await enableLock(page);
|
||||
await toMail(page);
|
||||
const prompts: string[] = [];
|
||||
page.on('dialog', (dialog) => { prompts.push(dialog.type()); void dialog.accept(); });
|
||||
await page.keyboard.press('c');
|
||||
const composer = page.locator('[data-testid="email-composer"]');
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
|
||||
await composer.locator('[contenteditable="true"]').first().click();
|
||||
await page.keyboard.type(words);
|
||||
|
||||
// As the timer does it, with the composer open.
|
||||
await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('bulwark-app-lock') ?? '{}');
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ ...raw, locked: true }));
|
||||
new BroadcastChannel('bulwark:app-lock').postMessage({ type: 'lock' });
|
||||
});
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 60_000 });
|
||||
expect(prompts).toEqual([]);
|
||||
await expect(page.locator('body')).not.toContainText(words);
|
||||
expect(await browserStorage(page)).not.toContain(words);
|
||||
|
||||
await expect.poll(async () => (await serverDraftBodies(subject)).join('\n'), { timeout: 60_000 }).toContain(words);
|
||||
|
||||
await unlock(page);
|
||||
await expect(page.getByRole('status').filter({ hasText: 'saved to Drafts' }).or(page.getByText('saved to Drafts')).first()).toBeVisible({ timeout: 30_000 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('time without use locks every tab, and use in another tab counts', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableLock(page);
|
||||
await page.getByTestId('app-lock-timeout').selectOption('1');
|
||||
const other = await context.newPage();
|
||||
await other.goto('/mail');
|
||||
await expect(other.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// 40 s of use in the other tab, then nothing.
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await other.mouse.move(100 + i * 5, 100);
|
||||
await other.waitForTimeout(10_000);
|
||||
await other.mouse.move(120 + i * 5, 120);
|
||||
}
|
||||
await page.waitForTimeout(25_000);
|
||||
await expect(lockForm(page), 'used 25 s ago in the other tab').toBeHidden();
|
||||
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 90_000 });
|
||||
await expect(lockForm(other)).toBeVisible({ timeout: 30_000 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a browser closed while unlocked starts locked', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableLock(page);
|
||||
const state = await context.storageState();
|
||||
// Web Locks and memory don't survive this. IndexedDB doesn't come along either, so localStorage alone decides here.
|
||||
await context.close();
|
||||
const reopened = await browser.newContext({ ...contextOptions(), storageState: state });
|
||||
const again = await reopened.newPage();
|
||||
await again.goto('/mail');
|
||||
await expect(lockForm(again)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(again.locator('[data-testid="email-list-item"]')).toHaveCount(0);
|
||||
await reopened.close();
|
||||
});
|
||||
|
||||
async function virtualAuthenticator(page: Page, hasPrf: boolean): Promise<void> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable', { enableUI: false });
|
||||
await cdp.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
ctap2Version: 'ctap2_1',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true,
|
||||
hasPrf,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('a passkey with PRF unlocks without the PIN, and the PIN keeps working', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await virtualAuthenticator(page, true);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
await page.getByTestId('app-lock-add-passkey').click();
|
||||
await page.getByTestId('app-lock-current-pin').fill(PIN);
|
||||
await page.getByTestId('app-lock-dialog-save').click();
|
||||
await expect(page.getByTestId('app-lock-dialog')).toBeHidden({ timeout: 60_000 });
|
||||
|
||||
await lockNow(page);
|
||||
await page.getByTestId('app-lock-passkey').click();
|
||||
await expect(lockForm(page)).toBeHidden({ timeout: 60_000 });
|
||||
await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 });
|
||||
await expect.poll(() => heldLocks(page), { timeout: 30_000 }).toContain('bulwark-store-owner');
|
||||
|
||||
await lockNow(page);
|
||||
await unlock(page);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a passkey with no PRF is refused when added, and the PIN stays', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await virtualAuthenticator(page, false);
|
||||
await enableLock(page);
|
||||
await page.getByTestId('app-lock-add-passkey').click();
|
||||
await page.getByTestId('app-lock-current-pin').fill(PIN);
|
||||
await page.getByTestId('app-lock-dialog-save').click();
|
||||
await expect(page.getByTestId('app-lock-dialog')).toContainText("can't protect the key", { timeout: 60_000 });
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await lockNow(page);
|
||||
await expect(page.getByTestId('app-lock-passkey')).toHaveCount(0);
|
||||
await unlock(page);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a login without "Remember me" has to sign in again after a lock, and its local data waits', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const page = await context.newPage();
|
||||
// The server has the box switched off. This asks for it as a server with it on does: the rest is the real login.
|
||||
await page.route('**/api/config', async (route) => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, json: { ...(await response.json()), loginShowRememberMe: true, rememberMeEnabled: true } });
|
||||
});
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Remember me', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await signIn(page);
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry') ?? '{}').state.accounts[0].rememberMe)).toBe(false);
|
||||
// By the router: such a login doesn't survive a page load, with or without a lock.
|
||||
await enableOffline(page, softGoto);
|
||||
await enableLock(page, PIN, softGoto);
|
||||
const before = await keyRecords(page);
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await unlock(page);
|
||||
// The way to the login page and back from it are full page loads, and each asks again.
|
||||
const pastLock = async (target: ReturnType<Page['locator']>) => {
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await expect(target.or(lockForm(page)).first()).toBeVisible({ timeout: 60_000 });
|
||||
if (await target.first().isVisible()) return;
|
||||
await unlock(page);
|
||||
}
|
||||
throw new Error('still at the lock screen');
|
||||
};
|
||||
await pastLock(page.locator('#username'));
|
||||
// Not what a page reload does to such a login with the lock off: that one wipes.
|
||||
expect((await keyRecords(page)).map((record) => record.account)).toEqual(before.map((record) => record.account));
|
||||
await page.fill('#username', ENT.alice);
|
||||
await page.fill('#password', ENT_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await pastLock(accountSwitcher(page));
|
||||
await expect.poll(() => heldLocks(page), { timeout: 60_000 }).toContain('bulwark-store-owner');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('turning it off needs the PIN and takes the layer off', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
await page.getByTestId('app-lock-switch').click();
|
||||
await page.getByTestId('app-lock-current-pin').fill('wrong-pin-1');
|
||||
await page.getByTestId('app-lock-dialog-save').click();
|
||||
await expect(page.getByTestId('app-lock-dialog')).toContainText('not right', { timeout: 30_000 });
|
||||
await page.getByTestId('app-lock-current-pin').fill(PIN);
|
||||
await page.getByTestId('app-lock-dialog-save').click();
|
||||
await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 });
|
||||
for (const record of await keyRecords(page)) expect(record.layers).toEqual([]);
|
||||
// Off: a reload asks for nothing.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('app-lock-switch')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(lockForm(page)).toHaveCount(0);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
/** What must be gone once the PIN is forgotten, however the server behaved. */
|
||||
async function expectNothingLeft(page: Page): Promise<void> {
|
||||
expect(await page.evaluate(() => localStorage.getItem('bulwark-app-lock'))).toBeNull();
|
||||
await expect.poll(async () => page.evaluate(async () => (await indexedDB.databases()).map((db) => db.name)), { timeout: 30_000 })
|
||||
.not.toEqual(expect.arrayContaining(['bulwark-app-lock']));
|
||||
await expect.poll(async () => page.evaluate(async () => (await indexedDB.databases()).map((db) => db.name)), { timeout: 30_000 })
|
||||
.not.toEqual(expect.arrayContaining(['bulwark-store']));
|
||||
// The encrypted files themselves, in the browser's private folder.
|
||||
await expect.poll(() => page.evaluate(async () => {
|
||||
const names: string[] = [];
|
||||
for await (const name of (await navigator.storage.getDirectory() as unknown as { keys(): AsyncIterable<string> }).keys()) names.push(name);
|
||||
return names;
|
||||
}), { timeout: 30_000 }).toEqual([]);
|
||||
expect(await page.evaluate(() => localStorage.getItem('account-registry') ?? '')).not.toContain('@');
|
||||
}
|
||||
|
||||
const SESSION_COOKIE = /^jmap_/;
|
||||
|
||||
async function forgetPin(page: Page): Promise<void> {
|
||||
await page.getByTestId('app-lock-forgot').click();
|
||||
await page.getByRole('button', { name: 'Remove and sign out' }).click();
|
||||
}
|
||||
|
||||
test('a forgotten PIN: local data goes, everyone signs out, and the login page is next', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
await lockNow(page);
|
||||
await forgetPin(page);
|
||||
await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 });
|
||||
await expectNothingLeft(page);
|
||||
await expect.poll(async () => (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name)).map((cookie) => cookie.name), { timeout: 30_000 }).toEqual([]);
|
||||
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBeNull();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a forgotten PIN with a server that hangs: nothing waits for it, and nobody is signed in', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
await lockNow(page);
|
||||
// Sign-out's calls never answer.
|
||||
await context.route(/\/api\/(auth|settings|devices)/, () => { /* no answer */ });
|
||||
const started = Date.now();
|
||||
await forgetPin(page);
|
||||
await page.waitForURL((url) => !url.pathname.includes('/settings'), { timeout: 60_000 });
|
||||
expect(Date.now() - started, 'bounded, whatever the server does').toBeLessThan(45_000);
|
||||
await expectNothingLeft(page);
|
||||
// The session cookie stays, as the server never heard. The mark keeps it from signing anyone in.
|
||||
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBe('1');
|
||||
await context.unroute(/\/api\/(auth|settings|devices)/);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(accountSwitcher(page)).toHaveCount(0);
|
||||
await expect.poll(async () => (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name)).map((cookie) => cookie.name), { timeout: 30_000 }).toEqual([]);
|
||||
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBeNull();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a forgotten PIN offline: the cookie that stays signs nobody in, then or when the network is back', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
await lockNow(page);
|
||||
await context.setOffline(true);
|
||||
await forgetPin(page);
|
||||
// No network, so no way to delete an HttpOnly cookie, and the shell went with the rest:
|
||||
// the way to the login page ends at the browser's own error until the network is back.
|
||||
await page.waitForURL((url) => !url.pathname.includes('/settings'), { timeout: 60_000 })
|
||||
.catch((error: Error) => expect(error.message).toContain('ERR_INTERNET_DISCONNECTED'));
|
||||
// The old page is gone only once the error page is up: until then it could still reach the network.
|
||||
await expect.poll(() => page.url(), { timeout: 30_000 }).toContain('chrome-error');
|
||||
expect((await context.cookies()).some((cookie) => SESSION_COOKIE.test(cookie.name))).toBe(true);
|
||||
const stored = (await context.storageState()).origins.flatMap((origin) => origin.localStorage).map((entry) => entry.name);
|
||||
expect(stored).toContain('bulwark-cookies-to-clear');
|
||||
expect(stored).not.toContain('bulwark-app-lock');
|
||||
const asked: string[] = [];
|
||||
context.on('request', (request) => {
|
||||
if (/\/api\/auth\/session/.test(request.url())) asked.push(request.method());
|
||||
});
|
||||
await context.setOffline(false);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(accountSwitcher(page)).toHaveCount(0);
|
||||
await expectNothingLeft(page);
|
||||
await expect.poll(async () => (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name)).map((cookie) => cookie.name), { timeout: 30_000 }).toEqual([]);
|
||||
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBeNull();
|
||||
expect(asked[0], 'the DELETE goes before anything could adopt the cookie').toBe('DELETE');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('locked offline, with the shell kept: the page still reloads, so nothing stays in memory', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableOffline(page);
|
||||
await expect.poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration()) ? (await caches.keys()).filter((key) => key.startsWith('bulwark-shell-')).length : 0), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await enableLock(page);
|
||||
await page.goto('/settings/general');
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await unlock(page);
|
||||
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)), { timeout: 30_000 }).toBe(true);
|
||||
await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; });
|
||||
await context.setOffline(true);
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 60_000 });
|
||||
expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before), 'a new document').toBeUndefined();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a server answering 5xx: the lock holds on the page, and the reload comes when it can', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
await enableLock(page);
|
||||
await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; });
|
||||
await context.route('**/settings/general*', (route) => route.fulfill({ status: 503, body: 'down' }));
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 60_000 });
|
||||
expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before), 'stayed').toBe('still here');
|
||||
await expect(page.locator('body')).not.toContainText('Lock now');
|
||||
await context.unroute('**/settings/general*');
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
// The reload can come in the middle of a look.
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __before?: string }).__before).catch(() => 'reloading'), { timeout: 30_000 }).toBeUndefined();
|
||||
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a device at the lock screen hears a remote wipe, and what it hears takes the data', async ({ browser }) => {
|
||||
const { page, context } = await fresh(browser);
|
||||
let wipeWaits = false;
|
||||
let checkedInLocked = false;
|
||||
// The registry itself is the remote wipe spec's to test: here it says what it says for a wipe.
|
||||
await context.route('**/api/devices', async (route) => {
|
||||
const body = route.request().postDataJSON() as { id?: string } | null;
|
||||
if (wipeWaits && body?.id) {
|
||||
checkedInLocked = true;
|
||||
await route.fulfill({ json: { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } } });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await enableOffline(page);
|
||||
await enableLock(page);
|
||||
await expect.poll(() => page.evaluate(() => new Promise<number>((resolve) => {
|
||||
const open = indexedDB.open('bulwark-store');
|
||||
open.onsuccess = () => {
|
||||
const all = open.result.transaction('keys').objectStore('keys').getAllKeys();
|
||||
all.onsuccess = () => {
|
||||
open.result.close();
|
||||
resolve(all.result.filter((key) => String(key).startsWith('device:')).length);
|
||||
};
|
||||
};
|
||||
})), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
|
||||
wipeWaits = true;
|
||||
await page.getByTestId('app-lock-now').click();
|
||||
// The lock screen comes first, and nobody unlocks.
|
||||
await expect(page.locator('#username').or(lockForm(page)).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 });
|
||||
expect(checkedInLocked).toBe(true);
|
||||
await expectNothingLeft(page);
|
||||
await context.close();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// With the app lock on, a full page load in the tab that holds the key loses the key, and
|
||||
// the user is asked for the PIN again. So the app's own moves use the router. A load that
|
||||
// has to be one says why on the line before: `// full-load: the reason`.
|
||||
|
||||
const ROOTS = ['hooks', 'lib/plugin-sandbox'];
|
||||
const LOAD = /location\.(assign|reload|replace)\(|location\.href\s*=[^=]/;
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const full = path.join(dir, name);
|
||||
if (statSync(full).isDirectory()) return name === '__tests__' ? [] : sources(full);
|
||||
return /\.tsx?$/.test(name) ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('full page loads', () => {
|
||||
it('none without a reason in the paths the app moves around in', () => {
|
||||
const unexplained: string[] = [];
|
||||
for (const file of ROOTS.flatMap(sources)) {
|
||||
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
if (LOAD.test(line) && !/full-load:/.test(lines[index - 1] ?? '')) unexplained.push(`${file}:${index + 1}`);
|
||||
});
|
||||
}
|
||||
expect(unexplained).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Nothing under the gate hydrates while locked. A stolen device at the lock screen still
|
||||
// has to hear a wipe, so the host that checks in sits beside the gate, not under it.
|
||||
|
||||
describe('the wipe host', () => {
|
||||
it('is mounted outside the app lock gate', () => {
|
||||
const layout = readFileSync('app/(main)/[locale]/layout.tsx', 'utf8');
|
||||
const closes = layout.indexOf('</AppLockGate>');
|
||||
const opens = layout.indexOf('<AppLockGate>');
|
||||
const host = layout.indexOf('<DeviceWipeHost');
|
||||
expect(host).toBeGreaterThan(0);
|
||||
expect(opens).toBeGreaterThan(0);
|
||||
expect(host < opens || host > closes).toBe(true);
|
||||
expect(layout.indexOf('</IntlProvider>')).toBeGreaterThan(host);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,16 @@ describe('server-side SSO', () => {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
it('asks the provider for a real login after a forgotten app lock PIN, and only then', async () => {
|
||||
const authorize = async (extra: Record<string, unknown>) => {
|
||||
const started = await ssoStart(post('/api/auth/sso/start', { redirect_uri: 'https://webmail.example/en/auth/callback', ...extra }));
|
||||
return new URL((await started.json()).authorize_url).searchParams.get('prompt');
|
||||
};
|
||||
expect(await authorize({})).toBeNull();
|
||||
expect(await authorize({ fresh_login: true })).toBe('login');
|
||||
expect(await authorize({ fresh_login: 'yes' }), 'only the page\'s own true').toBeNull();
|
||||
});
|
||||
|
||||
it('carries an unticked box from start to complete', async () => {
|
||||
const body = await signIn({ remember: false });
|
||||
expect(body.remember).toBe(false);
|
||||
|
||||
@@ -71,9 +71,9 @@ export function loadWorker(opts: {
|
||||
await Promise.all(waits);
|
||||
}
|
||||
/** A fetch event: what the worker answered, or null when it left the request to the network. */
|
||||
async function request(req: { url: string; method?: string; mode?: string }): Promise<Response | null> {
|
||||
async function request(req: { url: string; method?: string; mode?: string; headers?: Headers }): Promise<Response | null> {
|
||||
let answer: Promise<Response> | null = null;
|
||||
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise<Response>) => { answer = p; } });
|
||||
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', headers: new Headers(), ...req }, respondWith: (p: Promise<Response>) => { answer = p; } });
|
||||
return answer ? await answer : null;
|
||||
}
|
||||
return { dispatch, listeners, request, shown, skipped: () => skipped };
|
||||
|
||||
@@ -20,6 +20,52 @@ async function shellOn(caches: FakeCaches, on: boolean) {
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe('the app lock\'s question whether a reload would land anywhere', () => {
|
||||
const probe = new Headers({ 'x-bulwark-reload-probe': '1' });
|
||||
|
||||
it('is answered from the shell while offline: a reload would work', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
let online = true;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
if (!online) throw new TypeError('offline');
|
||||
return sameOrigin('<html>');
|
||||
});
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' });
|
||||
await settle();
|
||||
online = false;
|
||||
const answer = await worker.request({ url: `${ORIGIN}/mail`, headers: probe });
|
||||
expect(answer?.status).toBe(200);
|
||||
});
|
||||
|
||||
it('gives the 5xx through: a reload would land on that', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => new Response('', { status: 502 })) });
|
||||
await settle();
|
||||
const answer = await worker.request({ url: `${ORIGIN}/mail`, headers: probe });
|
||||
expect(answer?.status).toBe(502);
|
||||
});
|
||||
|
||||
it('fails with no shell and no network, so the page knows to stay', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => { throw new TypeError('offline'); }) });
|
||||
await settle();
|
||||
await expect(worker.request({ url: `${ORIGIN}/mail`, headers: probe })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('leaves any other fetch of a page to the network', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => sameOrigin('<html>')) });
|
||||
await settle();
|
||||
expect(await worker.request({ url: `${ORIGIN}/mail` })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker with offline mode off', () => {
|
||||
it('leaves every request to the network and caches nothing', async () => {
|
||||
const caches = new FakeCaches();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { FakeCaches, loadWorker } from './sw-harness';
|
||||
|
||||
// App lock: while locked a notification names no sender and no subject, and the
|
||||
// worker doesn't even ask the server for them. It can't hear a browser closing, so
|
||||
// "unlocked" only counts with a window open.
|
||||
|
||||
const PREVIEW = { unreadTotal: 1, slot: 0, email: { id: 'e1', threadId: 't1', subject: 'Secret subject', from: [{ name: 'Secret sender', email: 's@example.org' }] } };
|
||||
const delivery = { kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] };
|
||||
|
||||
function setup(clients: Array<{ id: string }>, notifications: Array<Record<string, unknown>> = []) {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify(PREVIEW), { status: 200 }));
|
||||
const worker = loadWorker({ buildId: 'b', caches: new FakeCaches(), clients, fetch: fetchMock, notifications });
|
||||
return { worker, fetchMock };
|
||||
}
|
||||
|
||||
describe('a push while the app lock is on', () => {
|
||||
it('names the sender with no lock at all', async () => {
|
||||
const { worker, fetchMock } = setup([]);
|
||||
await worker.dispatch('push', { data: { json: () => delivery } });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(worker.shown[0].title).toContain('Secret sender');
|
||||
});
|
||||
|
||||
it('says nothing about the message while locked, and asks nothing', async () => {
|
||||
const { worker, fetchMock } = setup([{ id: 'c' }]);
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } });
|
||||
await worker.dispatch('push', { data: { json: () => delivery } });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(worker.shown)).not.toContain('Secret');
|
||||
expect(worker.shown[0].title).toBe('New mail');
|
||||
});
|
||||
|
||||
it('names it again after unlock, with a window open', async () => {
|
||||
const { worker } = setup([{ id: 'c' }]);
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } });
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'unlocked' } });
|
||||
await worker.dispatch('push', { data: { json: () => delivery } });
|
||||
expect(worker.shown[0].title).toContain('Secret sender');
|
||||
});
|
||||
|
||||
it('counts as locked with no window open: the key is in a window or nowhere', async () => {
|
||||
const { worker, fetchMock } = setup([]);
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'unlocked' } });
|
||||
await worker.dispatch('push', { data: { json: () => delivery } });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(worker.shown)).not.toContain('Secret');
|
||||
});
|
||||
|
||||
it('names it again once the lock is off', async () => {
|
||||
const { worker } = setup([]);
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } });
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'off' } });
|
||||
await worker.dispatch('push', { data: { json: () => delivery } });
|
||||
expect(worker.shown[0].title).toContain('Secret sender');
|
||||
});
|
||||
|
||||
it('closes what\'s already on screen when it locks', async () => {
|
||||
const close = vi.fn();
|
||||
const { worker } = setup([{ id: 'c' }], [
|
||||
{ tag: 'bulwark-mail:a', title: 'Secret sender', close },
|
||||
]);
|
||||
await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } });
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,775 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { composeSnapshots, registerComposeFlush, registerComposeSnapshot, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
|
||||
import { isAppLocked, setAppLockCheck } from '@/lib/desktop/app-lock-state';
|
||||
import { configureStore, getStore, setStoreBackend, type StorePageHooks } from '@/lib/store/client/api';
|
||||
import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys';
|
||||
import {
|
||||
addOsUnlock,
|
||||
appLockHold,
|
||||
changeAppLockPin,
|
||||
appLockKeyLayers,
|
||||
bootAppLock,
|
||||
disableAppLock,
|
||||
enableAppLock,
|
||||
forgetEverything,
|
||||
getAppLockPhase,
|
||||
lockEverywhere,
|
||||
resetAppLockForTests,
|
||||
setReloadForTests,
|
||||
startAppLock,
|
||||
STAY_RETRY_MS,
|
||||
unlockWithOs,
|
||||
unlockWithPin,
|
||||
} from '../controller';
|
||||
import { LOCK_LAYER } from '../crypto';
|
||||
import { appLockRequired, effectiveTimeout } from '../policy';
|
||||
import { APP_LOCK_KEY, isAppLockedNow, readAppLockFile, setLockedHere } from '../state';
|
||||
import { createVault, deleteVault, readVault, sealDraft, VaultExistsError } from '../vault';
|
||||
|
||||
const PIN = 'correct-pin';
|
||||
|
||||
class FakeChannel {
|
||||
static open = new Set<FakeChannel>();
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
constructor(readonly name: string) {
|
||||
FakeChannel.open.add(this);
|
||||
}
|
||||
postMessage(data: unknown): void {
|
||||
for (const channel of FakeChannel.open) {
|
||||
if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
close(): void {
|
||||
FakeChannel.open.delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWorker {
|
||||
static all: FakeWorker[] = [];
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
posted: Array<Record<string, unknown>> = [];
|
||||
terminated = false;
|
||||
constructor() {
|
||||
FakeWorker.all.push(this);
|
||||
// As the real one does with no other tab around.
|
||||
queueMicrotask(() => this.onmessage?.({ data: { kind: 'owner' } } as MessageEvent));
|
||||
}
|
||||
postMessage(message: Record<string, unknown>): void {
|
||||
this.posted.push(message);
|
||||
if (message.kind === 'open' || message.kind === 'release' || message.kind === 'prune-files') {
|
||||
queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: message.kind === 'open' ? { created: false } : undefined } } as MessageEvent));
|
||||
}
|
||||
}
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const hooks: StorePageHooks = {
|
||||
inputs: () => ({ session: { apiUrl: 'x' } as never, auth: 'Bearer token', invertedKeywordSort: null }),
|
||||
refreshAuth: async () => null,
|
||||
onEvent: () => {},
|
||||
keyLayers: appLockKeyLayers,
|
||||
};
|
||||
|
||||
let reloads = 0;
|
||||
let stopTab: () => void = () => {};
|
||||
|
||||
function bridge(overrides: Record<string, unknown> = {}) {
|
||||
const appLock = {
|
||||
status: vi.fn(async () => ({ locked: false, enrolled: true, os: 'hello', osEnrolled: true })),
|
||||
lock: vi.fn(),
|
||||
enroll: vi.fn(async () => true),
|
||||
prove: vi.fn(async () => true),
|
||||
disable: vi.fn(async () => true),
|
||||
forget: vi.fn(async () => true),
|
||||
osEnroll: vi.fn(async () => true),
|
||||
osRemove: vi.fn(async () => {}),
|
||||
osUnlock: vi.fn(async () => ({ ok: false as const, reason: 'denied' as const })),
|
||||
onLockRequest: vi.fn(() => () => {}),
|
||||
claim: vi.fn(() => ({ lock: appLock.lock, osUnlock: appLock.osUnlock })),
|
||||
wrap: vi.fn(async () => null),
|
||||
unwrap: vi.fn(async () => null),
|
||||
...overrides,
|
||||
};
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock };
|
||||
return appLock;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
FakeChannel.open.clear();
|
||||
FakeWorker.all = [];
|
||||
vi.stubGlobal('BroadcastChannel', FakeChannel);
|
||||
vi.stubGlobal('Worker', FakeWorker);
|
||||
vi.stubGlobal('FileSystemFileHandle', class {});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200 })));
|
||||
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => ({}) } });
|
||||
Object.defineProperty(window.navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: {
|
||||
request: vi.fn(async (_name: string, ...rest: unknown[]) => {
|
||||
const run = rest[rest.length - 1] as () => unknown;
|
||||
return run();
|
||||
}),
|
||||
query: vi.fn(async () => ({ held: [], pending: [] })),
|
||||
},
|
||||
});
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
reloads = 0;
|
||||
setReloadForTests(() => { reloads += 1; });
|
||||
resetAppLockForTests();
|
||||
resetComposeFlushRegistry();
|
||||
setStoreBackend(null);
|
||||
configureStore(hooks);
|
||||
await deleteVault();
|
||||
deleteKeyDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopTab();
|
||||
stopTab = () => {};
|
||||
vi.unstubAllGlobals();
|
||||
setReloadForTests(null);
|
||||
setAppLockCheck(null);
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
delete (window.navigator as { storage?: unknown }).storage;
|
||||
delete (window.navigator as { locks?: unknown }).locks;
|
||||
setStoreBackend(null);
|
||||
configureStore(null);
|
||||
setLockedHere(false);
|
||||
});
|
||||
|
||||
/** A store open under the lock, as a running tab has it. */
|
||||
async function runningLockedStore(account = 'acc') {
|
||||
await enableAppLock(PIN);
|
||||
await getStore().ensureOpen(account, { apiUrl: 'x' } as never);
|
||||
await vi.waitFor(() => expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(true));
|
||||
return lastWorker();
|
||||
}
|
||||
|
||||
function lastWorker(): FakeWorker {
|
||||
return FakeWorker.all[FakeWorker.all.length - 1];
|
||||
}
|
||||
|
||||
async function lockedAndReloaded(): Promise<void> {
|
||||
lockEverywhere();
|
||||
await vi.waitFor(() => expect(reloads).toBe(1));
|
||||
}
|
||||
|
||||
/** The page a reload gives: new module state, the same storage. */
|
||||
function asNewDocument(): void {
|
||||
resetAppLockForTests();
|
||||
bootAppLock();
|
||||
}
|
||||
|
||||
describe('while locked', () => {
|
||||
it('the worker with the key is gone, the key with it, and reads fail until unlock', async () => {
|
||||
const worker = await runningLockedStore();
|
||||
const opened = worker.posted.find((m) => m.kind === 'open') as { params: { keyHex: string } };
|
||||
expect(opened.params.keyHex).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
await lockedAndReloaded();
|
||||
|
||||
expect(worker.posted.some((m) => m.kind === 'release')).toBe(true);
|
||||
expect(worker.terminated).toBe(true);
|
||||
const workers = FakeWorker.all.length;
|
||||
await expect(getStore().answer('acc', [])).rejects.toMatchObject({ name: 'StoreLockedError' });
|
||||
await expect(getStore().area('acc', 'x.y')).rejects.toMatchObject({ name: 'StoreLockedError' });
|
||||
expect(FakeWorker.all.length, 'no new worker while locked').toBe(workers);
|
||||
// In this tab's memory too: nothing gives the layer now.
|
||||
expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' }));
|
||||
await expect(openAccountKey('acc')).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' });
|
||||
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex: opened.params.keyHex });
|
||||
});
|
||||
|
||||
it('a stuck close doesn\'t keep the key: the worker is stopped after the wait', async () => {
|
||||
const worker = await runningLockedStore();
|
||||
worker.postMessage = function postMessage(message) { this.posted.push(message); };
|
||||
await getStore().lock?.(10);
|
||||
expect(worker.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it('a new start stays closed: the app is held back and nothing has the key', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
asNewDocument();
|
||||
expect(getAppLockPhase()).toBe('locked');
|
||||
expect(appLockHold()).toBeInstanceOf(Promise);
|
||||
expect(isAppLockedNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('a running page isn\'t held back while it saves: composers stay mounted until the reload', async () => {
|
||||
await enableAppLock(PIN);
|
||||
let holdDuringSave: Promise<void> | null | undefined;
|
||||
registerComposeFlush(async () => { holdDuringSave = appLockHold(); });
|
||||
await lockedAndReloaded();
|
||||
expect(holdDuringSave).toBeNull();
|
||||
});
|
||||
|
||||
it('the check the OS code asks follows the lock both ways', async () => {
|
||||
expect(isAppLocked()).toBe(false);
|
||||
await enableAppLock(PIN);
|
||||
asNewDocument();
|
||||
expect(isAppLocked()).toBe(true);
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLocked()).toBe(false);
|
||||
});
|
||||
|
||||
it('a start that finds the app locked takes subjects out of storage too: a quit or a crash left them there', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}');
|
||||
localStorage.setItem('contact-storage', '{"state":{"contacts":[{"name":"Secret Person"}]}}');
|
||||
localStorage.setItem('notification-center', '[{"title":"Secret notice"}]');
|
||||
asNewDocument();
|
||||
await vi.waitFor(() => expect(JSON.stringify({ ...localStorage })).not.toContain('Secret'));
|
||||
});
|
||||
|
||||
it('so does a start after a crash while unlocked, once it finds nobody holds the key', async () => {
|
||||
await enableAppLock(PIN);
|
||||
resetAppLockForTests();
|
||||
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}');
|
||||
localStorage.setItem('notification-center', '[{"title":"Secret notice"}]');
|
||||
bootAppLock();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
|
||||
await vi.waitFor(() => expect(JSON.stringify({ ...localStorage })).not.toContain('Secret'));
|
||||
});
|
||||
|
||||
it('a start where another tab holds the key leaves storage alone', async () => {
|
||||
await enableAppLock(PIN);
|
||||
resetAppLockForTests();
|
||||
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Kept"}]}');
|
||||
(window.navigator.locks.query as ReturnType<typeof vi.fn>).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] });
|
||||
(window.navigator.locks.request as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
|
||||
bootAppLock();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('open'));
|
||||
expect(localStorage.getItem('email-snapshot')).toContain('Kept');
|
||||
});
|
||||
|
||||
it('contacts and notifications come back at unlock, sealed while locked', async () => {
|
||||
await enableAppLock(PIN);
|
||||
localStorage.setItem('contact-storage', '{"state":{"contacts":[{"name":"Secret Person"}]},"version":0}');
|
||||
localStorage.setItem('notification-center', JSON.stringify([{ id: 'n', time: 1, level: 'info', title: 'Secret notice', body: '', read: false }]));
|
||||
await lockedAndReloaded();
|
||||
expect(JSON.stringify({ ...localStorage })).not.toContain('Secret');
|
||||
await unlockWithPin(PIN);
|
||||
expect(localStorage.getItem('contact-storage')).toContain('Secret Person');
|
||||
expect(localStorage.getItem('notification-center')).toContain('Secret notice');
|
||||
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
|
||||
expect(useNotificationCenterStore.getState().entries.map((entry) => entry.title)).toContain('Secret notice');
|
||||
});
|
||||
|
||||
it('a newer copy replaces the sealed one instead of piling up', async () => {
|
||||
await enableAppLock(PIN);
|
||||
localStorage.setItem('notification-center', '[{"id":"n","title":"Old notice"}]');
|
||||
await lockedAndReloaded();
|
||||
localStorage.setItem('notification-center', '[{"id":"n","title":"New notice"}]');
|
||||
asNewDocument();
|
||||
await vi.waitFor(() => expect(localStorage.getItem('notification-center')).toBeNull());
|
||||
await unlockWithPin(PIN);
|
||||
expect(localStorage.getItem('notification-center')).toContain('New notice');
|
||||
});
|
||||
|
||||
it('a browser closed without locking counts as locked: nobody holds the key', async () => {
|
||||
await enableAppLock(PIN);
|
||||
resetAppLockForTests();
|
||||
expect(readAppLockFile().locked).toBe(false);
|
||||
bootAppLock();
|
||||
expect(getAppLockPhase()).toBe('checking');
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
|
||||
expect(readAppLockFile().locked).toBe(true);
|
||||
});
|
||||
|
||||
it('another tab holding the key lets this one start without any key', async () => {
|
||||
await enableAppLock(PIN);
|
||||
resetAppLockForTests();
|
||||
(window.navigator.locks.query as ReturnType<typeof vi.fn>).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] });
|
||||
// Held elsewhere: a request for it waits.
|
||||
(window.navigator.locks.request as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
|
||||
bootAppLock();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('open'));
|
||||
expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('locking', () => {
|
||||
it('saves what is being written before the store closes', async () => {
|
||||
const worker = await runningLockedStore();
|
||||
const order: string[] = [];
|
||||
registerComposeFlush(async () => { order.push(worker.terminated ? 'saved after close' : 'saved'); });
|
||||
await lockedAndReloaded();
|
||||
expect(order).toEqual(['saved']);
|
||||
});
|
||||
|
||||
it('seals text the server didn\'t take, and it comes back only after unlock', async () => {
|
||||
await enableAppLock(PIN);
|
||||
registerComposeFlush(async () => { throw new Error('offline'); });
|
||||
registerComposeSnapshot({ ownWindow: false, state: () => ({ subject: 'Unsaved secret', unsaved: true }) as never, draft: () => null });
|
||||
await lockedAndReloaded();
|
||||
expect(JSON.stringify({ ...localStorage })).not.toContain('Unsaved secret');
|
||||
expect(JSON.stringify({ ...sessionStorage })).not.toContain('Unsaved secret');
|
||||
await unlockWithPin(PIN);
|
||||
await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Unsaved secret'));
|
||||
});
|
||||
|
||||
it('takes subjects out of plain browser storage', async () => {
|
||||
await enableAppLock(PIN);
|
||||
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}');
|
||||
localStorage.setItem('pro-tabs', '{"tabs":[{"title":"Secret"}]}');
|
||||
sessionStorage.setItem('bulwark-popout-compose:h1', '{"initialData":{"subject":"Secret"}}');
|
||||
await lockedAndReloaded();
|
||||
expect(localStorage.getItem('email-snapshot')).toBeNull();
|
||||
expect(localStorage.getItem('pro-tabs')).toBeNull();
|
||||
expect(sessionStorage.getItem('bulwark-popout-compose:h1')).toBeNull();
|
||||
});
|
||||
|
||||
it('points a composer window at its saved draft, so nothing of the text stays in its address or session', async () => {
|
||||
await enableAppLock(PIN);
|
||||
registerComposeSnapshot({ ownWindow: true, state: () => ({ subject: 'x' }) as never, draft: () => ({ draftId: 'd7', localAccountId: 'alice' }) });
|
||||
await lockedAndReloaded();
|
||||
expect(window.location.search).toContain('draft=d7');
|
||||
expect(sessionStorage.getItem('bulwark-app-lock-drafts-saved')).toBe('1');
|
||||
expect(composeSnapshots()).toHaveLength(1);
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('reaches this tab from another, by the channel', async () => {
|
||||
await enableAppLock(PIN);
|
||||
stopTab = startAppLock();
|
||||
new FakeChannel('bulwark:app-lock').postMessage({ type: 'lock' });
|
||||
await vi.waitFor(() => expect(reloads).toBe(1));
|
||||
expect(isAppLockedNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('when the server is out of reach it stays on the page, off the screen, and reloads when it can', async () => {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
|
||||
await enableAppLock(PIN);
|
||||
lockEverywhere();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay'));
|
||||
expect(reloads).toBe(0);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
window.dispatchEvent(new Event('online'));
|
||||
await vi.waitFor(() => expect(reloads).toBe(1));
|
||||
});
|
||||
|
||||
it('asks in a way the service worker can answer: a GET that it knows is this probe', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls.at(-1) as [string, RequestInit];
|
||||
expect(init.method).toBe('GET');
|
||||
expect(new Headers(init.headers).get('x-bulwark-reload-probe')).toBe('1');
|
||||
});
|
||||
|
||||
it('a 5xx counts as out of reach: a reload would land on the error page', async () => {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 503 }));
|
||||
await enableAppLock(PIN);
|
||||
lockEverywhere();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay'));
|
||||
expect(reloads).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps trying on a timer: nothing says "online" when only the server was down', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
|
||||
try {
|
||||
(fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('down'));
|
||||
await enableAppLock(PIN);
|
||||
lockEverywhere();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay'));
|
||||
vi.advanceTimersByTime(STAY_RETRY_MS);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(reloads, 'still down').toBe(0);
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.advanceTimersByTime(STAY_RETRY_MS);
|
||||
await vi.waitFor(() => expect(reloads).toBe(1));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('a pop-out that unlocks with no main window leaves sealed drafts sealed, for the main window', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await sealDraft({ subject: 'Sealed text' });
|
||||
await lockedAndReloaded();
|
||||
window.history.replaceState(null, '', '/popout/compose');
|
||||
try {
|
||||
asNewDocument();
|
||||
await unlockWithPin(PIN);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionStorage.getItem('bulwark-rescued-drafts')).toBeNull();
|
||||
} finally {
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
lockEverywhere();
|
||||
asNewDocument();
|
||||
await unlockWithPin(PIN);
|
||||
await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Sealed text'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the store when a tab inherits it without the key', () => {
|
||||
it('asks for the lock instead of opening, or writing a key outside the layer', async () => {
|
||||
await enableAppLock(PIN);
|
||||
// This tab has no key: the one that had it went.
|
||||
resetAppLockForTests();
|
||||
(window.navigator.locks.query as ReturnType<typeof vi.fn>).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] });
|
||||
(window.navigator.locks.request as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
|
||||
bootAppLock();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('open'));
|
||||
const onKeyLocked = vi.fn();
|
||||
configureStore({ ...hooks, onKeyLocked });
|
||||
await expect(getStore().ensureOpen('new-account', { apiUrl: 'x' } as never).then(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await getStore().ensureOpen('new-account');
|
||||
})).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' });
|
||||
expect(onKeyLocked).toHaveBeenCalled();
|
||||
expect(await getAccountRecord('new-account')).toBeNull();
|
||||
expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(false);
|
||||
});
|
||||
|
||||
it('a new account after unlock gets its key under the lock', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await getStore().ensureOpen('new-account', { apiUrl: 'x' } as never);
|
||||
await vi.waitFor(async () => expect((await getAccountRecord('new-account'))?.layers).toEqual(['applock']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the desktop app', () => {
|
||||
it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => {
|
||||
const appLock = bridge();
|
||||
await enableAppLock(PIN);
|
||||
expect(appLock.enroll).toHaveBeenCalledTimes(1);
|
||||
const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array;
|
||||
await lockedAndReloaded();
|
||||
expect(appLock.lock).toHaveBeenCalled();
|
||||
await unlockWithPin(PIN);
|
||||
expect(appLock.prove).toHaveBeenCalledTimes(1);
|
||||
expect(Array.from(appLock.prove.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof));
|
||||
});
|
||||
|
||||
it('the screen locking locks the app', async () => {
|
||||
let request: () => void = () => {};
|
||||
bridge({ onLockRequest: vi.fn((listener: () => void) => { request = listener; return () => {}; }) });
|
||||
await enableAppLock(PIN);
|
||||
stopTab = startAppLock();
|
||||
request();
|
||||
await vi.waitFor(() => expect(reloads).toBe(1));
|
||||
expect(readAppLockFile().locked).toBe(true);
|
||||
});
|
||||
|
||||
it('a page in a tab can\'t lift a lock main still holds', async () => {
|
||||
const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) });
|
||||
await enableAppLock(PIN);
|
||||
resetAppLockForTests();
|
||||
bootAppLock();
|
||||
stopTab = startAppLock();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
|
||||
new FakeChannel('bulwark:app-lock').postMessage({ type: 'unlocked' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(appLock.status).toHaveBeenCalled();
|
||||
expect(getAppLockPhase()).toBe('locked');
|
||||
});
|
||||
|
||||
it('Windows Hello\'s yes opens with the key main hands over', async () => {
|
||||
let held: Uint8Array | null = null;
|
||||
const appLock = bridge({
|
||||
osEnroll: vi.fn(async (_proof: Uint8Array, key: Uint8Array) => { held = key.slice(); return true; }),
|
||||
osUnlock: vi.fn(async () => ({ ok: true as const, key: held! })),
|
||||
});
|
||||
await runningLockedStore();
|
||||
expect(await addOsUnlock(PIN)).toBe(true);
|
||||
await lockedAndReloaded();
|
||||
expect(await unlockWithOs()).toBe(true);
|
||||
expect(appLock.osUnlock).toHaveBeenCalledTimes(1);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('wrong bytes from main open nothing', async () => {
|
||||
bridge({ osUnlock: vi.fn(async () => ({ ok: true as const, key: new Uint8Array(32).fill(1) })) });
|
||||
await runningLockedStore();
|
||||
await lockedAndReloaded();
|
||||
await unlockWithOs().catch(() => false);
|
||||
await expect(openAccountKey('acc', appLockKeyLayers())).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('Windows Hello\'s no leaves it locked, and the PIN still works', async () => {
|
||||
const appLock = bridge();
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
expect(await unlockWithOs()).toBe(false);
|
||||
expect(appLock.osUnlock).toHaveBeenCalled();
|
||||
expect(isAppLockedNow()).toBe(true);
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('tells the OS integration when it locks, and its check says so until the unlock', async () => {
|
||||
bridge();
|
||||
const setLocked = vi.fn();
|
||||
(window as unknown as { bulwarkDesktop: Record<string, unknown> }).bulwarkDesktop.os = { setLocked };
|
||||
await enableAppLock(PIN);
|
||||
expect(setLocked).not.toHaveBeenCalled();
|
||||
await lockedAndReloaded();
|
||||
expect(setLocked).toHaveBeenCalledWith(true);
|
||||
setLocked.mockClear();
|
||||
asNewDocument();
|
||||
expect(setLocked, 'a new page behind the lock says so at once').toHaveBeenCalledWith(true);
|
||||
expect(isAppLocked()).toBe(true);
|
||||
await unlockWithPin(PIN);
|
||||
// What components/desktop/desktop-os-host.tsx tells main as the app starts.
|
||||
expect(isAppLocked()).toBe(false);
|
||||
});
|
||||
|
||||
it('a page takes the two calls a frame must not reach once, before any frame runs', async () => {
|
||||
const appLock = bridge();
|
||||
bootAppLock();
|
||||
expect(appLock.claim).toHaveBeenCalledTimes(1);
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
expect(appLock.lock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a claim someone else got first leaves this page with no OS unlock, and the PIN working', async () => {
|
||||
const appLock = bridge({ claim: vi.fn(() => null) });
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
expect(await unlockWithOs()).toBe(false);
|
||||
expect(appLock.osUnlock).not.toHaveBeenCalled();
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('a no from main on a good PIN isn\'t ignored: it enrolls again, and is unlocked', async () => {
|
||||
const appLock = bridge();
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
appLock.enroll.mockClear();
|
||||
appLock.prove.mockResolvedValue(false as never);
|
||||
await unlockWithPin(PIN);
|
||||
expect(appLock.enroll).toHaveBeenCalledTimes(1);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('main keeps an older lock: it asks natively, and only a yes replaces it', async () => {
|
||||
const appLock = bridge();
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
appLock.prove.mockResolvedValue(false as never);
|
||||
appLock.enroll.mockResolvedValueOnce(false as never);
|
||||
appLock.forget.mockResolvedValueOnce(false as never);
|
||||
await expect(unlockWithPin(PIN)).rejects.toThrow();
|
||||
expect(isAppLockedNow(), 'main still locked, so the page is').toBe(true);
|
||||
expect(appLock.enroll).toHaveBeenCalledTimes(2);
|
||||
|
||||
appLock.enroll.mockResolvedValueOnce(false as never);
|
||||
appLock.forget.mockResolvedValueOnce(true as never);
|
||||
appLock.enroll.mockResolvedValueOnce(true as never);
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('turning it off with main refusing the proof: it asks, and a no leaves everything on', async () => {
|
||||
const appLock = bridge({ disable: vi.fn(async () => false), forget: vi.fn(async () => false) });
|
||||
await openAccountKey('acc');
|
||||
await enableAppLock(PIN);
|
||||
await expect(disableAppLock(PIN)).rejects.toThrow();
|
||||
expect(readAppLockFile().on).toBe(true);
|
||||
expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']);
|
||||
expect(await readVault()).not.toBeNull();
|
||||
appLock.forget.mockResolvedValue(true);
|
||||
await disableAppLock(PIN);
|
||||
expect(readAppLockFile().on).toBe(false);
|
||||
});
|
||||
|
||||
it('turning it off when main has no lock at all just goes on', async () => {
|
||||
bridge({
|
||||
disable: vi.fn(async () => false),
|
||||
forget: vi.fn(async () => false),
|
||||
status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false })),
|
||||
});
|
||||
await enableAppLock(PIN);
|
||||
await disableAppLock(PIN);
|
||||
expect(readAppLockFile().on).toBe(false);
|
||||
});
|
||||
|
||||
it('a sign-out in the tab with the key shows main the proof, so nothing asks', async () => {
|
||||
const appLock = bridge();
|
||||
await enableAppLock(PIN);
|
||||
const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array;
|
||||
const { forgetAppLockAtSignOut } = await import('../sign-out');
|
||||
await forgetAppLockAtSignOut();
|
||||
expect(Array.from(appLock.forget.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof));
|
||||
expect(await readVault()).toBeNull();
|
||||
});
|
||||
|
||||
it('a record with main\'s safeStorage layer opens, and keeps it under the lock', async () => {
|
||||
bridge({
|
||||
status: vi.fn(async () => ({ locked: false, enrolled: true, os: null, osEnrolled: false, wrap: true })),
|
||||
wrap: vi.fn(async (data: Uint8Array) => Uint8Array.from([7, ...data])),
|
||||
unwrap: vi.fn(async (data: Uint8Array) => (data[0] === 7 ? data.slice(1) : null)),
|
||||
});
|
||||
const safe = {
|
||||
name: 'safeStorage',
|
||||
wrap: async (data: Uint8Array) => Uint8Array.from([7, ...data]),
|
||||
unwrap: async (data: Uint8Array) => data.slice(1),
|
||||
};
|
||||
const { keyHex } = await openAccountKey('acc', [safe]);
|
||||
await enableAppLock(PIN);
|
||||
expect((await getAccountRecord('acc'))?.layers).toEqual(['safeStorage', LOCK_LAYER]);
|
||||
await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex });
|
||||
// Off, nothing asks main at a start, so main's wrap goes too.
|
||||
await disableAppLock(PIN);
|
||||
expect((await getAccountRecord('acc'))?.layers).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgetting asks main first: a no changes nothing', async () => {
|
||||
const appLock = bridge({ forget: vi.fn(async () => false) });
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
expect(await forgetEverything()).toBe(false);
|
||||
expect(appLock.forget).toHaveBeenCalled();
|
||||
expect(readAppLockFile()).toMatchObject({ on: true, locked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy', () => {
|
||||
function withPolicy(required: { value: boolean; locked: boolean }, minutes: { value: number | null; locked: boolean } = { value: null, locked: false }) {
|
||||
const appLock = bridge();
|
||||
(window as unknown as { bulwarkDesktop: Record<string, unknown> }).bulwarkDesktop.policy = {
|
||||
get: () => ({
|
||||
AppLockRequired: { ...required, source: 'forced-machine' },
|
||||
AppLockTimeoutMinutes: { ...minutes, source: minutes.locked ? 'forced-machine' : 'default' },
|
||||
}),
|
||||
onChange: () => () => {},
|
||||
};
|
||||
return appLock;
|
||||
}
|
||||
|
||||
it('a forced policy requires it: a signed-in start waits for a PIN', () => {
|
||||
withPolicy({ value: true, locked: true });
|
||||
localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } }));
|
||||
expect(appLockRequired()).toBe(true);
|
||||
bootAppLock();
|
||||
expect(getAppLockPhase()).toBe('setup');
|
||||
expect(appLockHold()).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it('a required lock doesn\'t stand in front of the login page', async () => {
|
||||
withPolicy({ value: true, locked: true });
|
||||
bootAppLock();
|
||||
await vi.waitFor(() => expect(appLockHold()).toBeNull());
|
||||
expect(getAppLockPhase()).toBe('open');
|
||||
});
|
||||
|
||||
it('a recommended policy requires nothing', () => {
|
||||
withPolicy({ value: true, locked: false });
|
||||
expect(appLockRequired()).toBe(false);
|
||||
});
|
||||
|
||||
it('a required lock can\'t be turned off', async () => {
|
||||
await enableAppLock(PIN);
|
||||
withPolicy({ value: true, locked: true });
|
||||
await expect(disableAppLock(PIN)).rejects.toThrow();
|
||||
expect(readAppLockFile().on).toBe(true);
|
||||
});
|
||||
|
||||
it('setting up the required PIN lets the app go on', async () => {
|
||||
withPolicy({ value: true, locked: true });
|
||||
localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } }));
|
||||
bootAppLock();
|
||||
const hold = appLockHold();
|
||||
await enableAppLock(PIN);
|
||||
await expect(hold).resolves.toBeUndefined();
|
||||
expect(getAppLockPhase()).toBe('open');
|
||||
});
|
||||
|
||||
it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => {
|
||||
withPolicy({ value: false, locked: false }, { value: 3, locked: true });
|
||||
expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true });
|
||||
withPolicy({ value: false, locked: false }, { value: 3, locked: false });
|
||||
expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false });
|
||||
expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false });
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
expect(effectiveTimeout(undefined)).toEqual({ minutes: 10, locked: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a lost lock file', () => {
|
||||
it('a vault means on: the file going doesn\'t turn the lock off', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
localStorage.removeItem(APP_LOCK_KEY);
|
||||
asNewDocument();
|
||||
expect(appLockHold(), 'held back while it looks').toBeInstanceOf(Promise);
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
|
||||
expect(readAppLockFile()).toMatchObject({ on: true, locked: true });
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
});
|
||||
|
||||
it('so does an unreadable one', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await lockedAndReloaded();
|
||||
localStorage.setItem(APP_LOCK_KEY, '{broken');
|
||||
asNewDocument();
|
||||
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
|
||||
});
|
||||
|
||||
it('turning it on doesn\'t overwrite a vault that still holds a key', async () => {
|
||||
await openAccountKey('acc');
|
||||
await enableAppLock(PIN);
|
||||
localStorage.removeItem(APP_LOCK_KEY);
|
||||
const before = await readVault();
|
||||
resetAppLockForTests();
|
||||
await expect(enableAppLock('another-pin-82')).rejects.toBeInstanceOf(VaultExistsError);
|
||||
expect((await readVault())?.pin.data).toEqual(before?.pin.data);
|
||||
await expect(createVault('another-pin-82')).rejects.toBeInstanceOf(VaultExistsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a PIN anyone would guess', () => {
|
||||
it('is refused when set, and changing to one too', async () => {
|
||||
await expect(enableAppLock('123456')).rejects.toMatchObject({ name: 'ObviousPinError' });
|
||||
expect(await readVault()).toBeNull();
|
||||
await enableAppLock(PIN);
|
||||
await expect(changeAppLockPin(PIN, '000000')).rejects.toMatchObject({ name: 'ObviousPinError' });
|
||||
await unlockAfterChangeRefused();
|
||||
});
|
||||
|
||||
async function unlockAfterChangeRefused(): Promise<void> {
|
||||
await lockedAndReloaded();
|
||||
await unlockWithPin(PIN);
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
describe('off', () => {
|
||||
it('is the default: nothing is shown as locked and no layer is asked for', async () => {
|
||||
bootAppLock();
|
||||
await vi.waitFor(() => expect(appLockHold()).toBeNull());
|
||||
expect(getAppLockPhase()).toBe('open');
|
||||
expect(isAppLockedNow()).toBe(false);
|
||||
expect(appLockKeyLayers()).toEqual([]);
|
||||
expect(localStorage.getItem(APP_LOCK_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('turning it off takes the layer off again', async () => {
|
||||
await openAccountKey('acc');
|
||||
await enableAppLock(PIN);
|
||||
expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']);
|
||||
await disableAppLock(PIN);
|
||||
expect((await getAccountRecord('acc'))?.layers).toEqual([]);
|
||||
expect(readAppLockFile().on).toBe(false);
|
||||
});
|
||||
|
||||
it('a wrong PIN doesn\'t turn it off', async () => {
|
||||
await enableAppLock(PIN);
|
||||
await expect(disableAppLock('wrong-pin')).rejects.toMatchObject({ name: 'WrongPinError' });
|
||||
expect(readAppLockFile().on).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The way out when the PIN is gone. The order is what matters: nothing may be left
|
||||
// signed in with no PIN, and no record may keep the layer with no vault, whatever
|
||||
// the server does and wherever it stops.
|
||||
|
||||
const events = vi.hoisted(() => [] as string[]);
|
||||
const logoutAll = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ logoutAll }) } }));
|
||||
vi.mock('@/lib/store/client/purge', async () => {
|
||||
const keys = await vi.importActual<typeof import('@/lib/store/client/keys')>('@/lib/store/client/keys');
|
||||
return {
|
||||
purgeStoreFiles: async () => {
|
||||
events.push('keys');
|
||||
keys.deleteKeyDatabase();
|
||||
},
|
||||
deleteStorePool: async () => {},
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/offline/device-wipe', () => ({ forgetDeviceRows: async () => { events.push('rows'); } }));
|
||||
|
||||
import { getAccountRecord, listAccountRecords, openAccountKey } from '@/lib/store/client/keys';
|
||||
import { enableAppLock, forgetEverything, lockEverywhere, resetAppLockForTests, setLeaveForTests, setReloadForTests } from '../controller';
|
||||
import { readAppLockFile } from '../state';
|
||||
import { deleteVault, readVault } from '../vault';
|
||||
import { COOKIES_TO_CLEAR_KEY, FRESH_LOGIN_KEY } from '../sign-out';
|
||||
|
||||
const PIN = 'correct-pin';
|
||||
|
||||
class FakeChannel {
|
||||
onmessage = null;
|
||||
postMessage(): void {}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
let left = 0;
|
||||
|
||||
beforeEach(async () => {
|
||||
events.length = 0;
|
||||
logoutAll.mockReset();
|
||||
left = 0;
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal('BroadcastChannel', FakeChannel);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
setReloadForTests(() => {});
|
||||
setLeaveForTests(() => { left += 1; });
|
||||
resetAppLockForTests();
|
||||
await deleteVault();
|
||||
localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } }));
|
||||
await openAccountKey('a');
|
||||
await enableAppLock(PIN);
|
||||
lockEverywhere();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setReloadForTests(null);
|
||||
setLeaveForTests(null);
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
describe('forgetting the PIN', () => {
|
||||
it('a server that hangs leaves nothing behind: no registry, no keys, no vault, no lock', async () => {
|
||||
logoutAll.mockImplementation(() => new Promise(() => {}));
|
||||
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
|
||||
expect(await forgetEverything()).toBe(true);
|
||||
expect(left).toBe(1);
|
||||
expect(localStorage.getItem('account-registry')).toBeNull();
|
||||
expect(await listAccountRecords()).toEqual([]);
|
||||
expect(await readVault()).toBeNull();
|
||||
expect(readAppLockFile().on).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it('removes the registry and the keys first, lifts the lock last, and only then logs out', async () => {
|
||||
logoutAll.mockImplementation(async () => {
|
||||
events.push('logout');
|
||||
expect(localStorage.getItem('account-registry')).toBeNull();
|
||||
expect(await getAccountRecord('a')).toBeNull();
|
||||
expect(await readVault()).toBeNull();
|
||||
expect(readAppLockFile().on).toBe(false);
|
||||
});
|
||||
expect(await forgetEverything()).toBe(true);
|
||||
expect([...new Set(events)]).toEqual(['rows', 'keys', 'logout']);
|
||||
});
|
||||
|
||||
it('a key database that won\'t go keeps the vault and the lock', async () => {
|
||||
const keys = await import('@/lib/store/client/keys');
|
||||
const stuck = vi.spyOn(keys, 'forgetAccountKey').mockRejectedValue(new Error('held'));
|
||||
const list = vi.spyOn(keys, 'listAccountRecords').mockResolvedValue([{ account: 'a', layers: ['applock'] }] as never);
|
||||
try {
|
||||
expect(await forgetEverything()).toBe(false);
|
||||
expect(left).toBe(0);
|
||||
expect(await readVault()).not.toBeNull();
|
||||
expect(readAppLockFile().on).toBe(true);
|
||||
expect(logoutAll).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stuck.mockRestore();
|
||||
list.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a mark while the cookies couldn\'t be cleared, and asks for a real login next', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); }));
|
||||
logoutAll.mockResolvedValue(undefined);
|
||||
expect(await forgetEverything()).toBe(true);
|
||||
expect(localStorage.getItem(COOKIES_TO_CLEAR_KEY)).toBe('1');
|
||||
expect(localStorage.getItem(FRESH_LOGIN_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('takes the cookie mark off once the server said it cleared them', async () => {
|
||||
logoutAll.mockResolvedValue(undefined);
|
||||
expect(await forgetEverything()).toBe(true);
|
||||
expect(localStorage.getItem(COOKIES_TO_CLEAR_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(FRESH_LOGIN_KEY), 'the provider\'s session is still open').toBe('1');
|
||||
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls.map(([url, init]) => `${(init as RequestInit)?.method} ${url}`);
|
||||
expect(calls).toContain('DELETE /api/auth/session?all=true');
|
||||
expect(calls).toContain('DELETE /api/auth/token?all=true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { lastActivity, noteActivity, watchIdle } from '../idle';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
describe('time without use', () => {
|
||||
let stop: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function watch(minutes: number) {
|
||||
const onIdle = vi.fn();
|
||||
stop = watchIdle({ timeoutMs: () => minutes * MINUTE, onIdle });
|
||||
return onIdle;
|
||||
}
|
||||
|
||||
it('locks once the time has passed with nothing happening', () => {
|
||||
const onIdle = watch(5);
|
||||
vi.advanceTimersByTime(5 * MINUTE - 20_000);
|
||||
expect(onIdle).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('using this window starts it over', () => {
|
||||
const onIdle = watch(5);
|
||||
vi.advanceTimersByTime(4 * MINUTE);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown'));
|
||||
vi.advanceTimersByTime(4 * MINUTE);
|
||||
expect(onIdle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('using another window or tab counts too', () => {
|
||||
const onIdle = watch(5);
|
||||
vi.advanceTimersByTime(4 * MINUTE);
|
||||
// Another tab writes the shared key.
|
||||
noteActivity(Date.now());
|
||||
vi.advanceTimersByTime(4 * MINUTE);
|
||||
expect(onIdle).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(2 * MINUTE);
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tells the other tabs about use here, but not on every mouse move', () => {
|
||||
watch(5);
|
||||
const before = lastActivity();
|
||||
vi.advanceTimersByTime(1000);
|
||||
window.dispatchEvent(new Event('pointermove'));
|
||||
expect(lastActivity()).toBe(before);
|
||||
vi.advanceTimersByTime(5000);
|
||||
window.dispatchEvent(new Event('pointermove'));
|
||||
expect(lastActivity()).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('coming back after the time counts as too late, not as use', () => {
|
||||
const onIdle = watch(5);
|
||||
// The machine slept: no timer ran, and the clock jumped.
|
||||
vi.setSystemTime(Date.now() + 6 * MINUTE);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown'));
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a changed timeout applies at once', () => {
|
||||
let minutes = 60;
|
||||
const onIdle = vi.fn();
|
||||
stop = watchIdle({ timeoutMs: () => minutes * MINUTE, onIdle });
|
||||
vi.advanceTimersByTime(2 * MINUTE);
|
||||
expect(onIdle).not.toHaveBeenCalled();
|
||||
minutes = 1;
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires once and stops listening', () => {
|
||||
const onIdle = watch(1);
|
||||
vi.advanceTimersByTime(2 * MINUTE);
|
||||
vi.advanceTimersByTime(10 * MINUTE);
|
||||
window.dispatchEvent(new KeyboardEvent('keydown'));
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getAccountRecord, openAccountKey, rewrapAccountKey, StoreKeyLayerMissingError } from '@/lib/store/client/keys';
|
||||
import { importLockKey, lockLayer, mainProof, PBKDF2_ITERATIONS, pinIsObvious, pinLongEnough } from '../crypto';
|
||||
import { changeVaultPin, createVault, deleteVault, FREE_FAILURES, LONGEST_WAIT_MS, openSealedDrafts, openVaultWithPin, PinBlockedError, pinBlockedUntil, readVault, sealDraft, waitAfter, WrongPinError } from '../vault';
|
||||
|
||||
const PIN = 'correct-pin';
|
||||
|
||||
function hex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteVault();
|
||||
});
|
||||
|
||||
describe('the PIN', () => {
|
||||
it('uses PBKDF2 with at least 600,000 iterations and a random salt', async () => {
|
||||
(await createVault(PIN)).fill(0);
|
||||
const first = await readVault();
|
||||
expect(first?.pin.iterations).toBeGreaterThanOrEqual(600_000);
|
||||
expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000);
|
||||
await deleteVault();
|
||||
(await createVault(PIN)).fill(0);
|
||||
const second = await readVault();
|
||||
expect(hex(second!.pin.salt)).not.toBe(hex(first!.pin.salt));
|
||||
});
|
||||
|
||||
it('opens the lock key with the right PIN and not with a wrong one', async () => {
|
||||
const raw = await createVault(PIN);
|
||||
expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw));
|
||||
await expect(openVaultWithPin('wrong-pin')).rejects.toBeInstanceOf(WrongPinError);
|
||||
});
|
||||
|
||||
it('keeps nothing readable: the lock key isn\'t in what is stored', async () => {
|
||||
const raw = await createVault(PIN);
|
||||
const stored = await readVault();
|
||||
const bytes = hex(stored!.pin.data);
|
||||
expect(bytes).not.toContain(hex(raw));
|
||||
expect(JSON.stringify(stored)).not.toContain(PIN);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'000000', '999999', 'aaaaaa', '123456', '654321', '234567', '012345', '1234567890', 'abcdef', 'ABCDEF', 'fedcba',
|
||||
'121212', '123123', 'abcabc', '112233', 'password', 'Password1', 'qwerty', 'qwertz', '147258', '159753', '1q2w3e', 'letmein',
|
||||
])('refuses %s as too easy to guess', (pin) => {
|
||||
expect(pinIsObvious(pin)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['it-pin-4827', 'correct-pin', '482913', '135792', 'horse battery', '1234567a9'])('takes %s', (pin) => {
|
||||
expect(pinIsObvious(pin)).toBe(false);
|
||||
});
|
||||
|
||||
it('must be long enough, counted in characters', () => {
|
||||
expect(pinLongEnough('12345')).toBe(false);
|
||||
expect(pinLongEnough('123456')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in the desktop app, with main\'s safeStorage', () => {
|
||||
function shell(overrides: Record<string, unknown> = {}) {
|
||||
const appLock = {
|
||||
status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false, wrap: true })),
|
||||
wrap: vi.fn(async (data: Uint8Array) => Uint8Array.from([9, ...data])),
|
||||
unwrap: vi.fn(async (data: Uint8Array) => (data[0] === 9 ? data.slice(1) : null)),
|
||||
...overrides,
|
||||
};
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock };
|
||||
return appLock;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
it('goes through main, and opens only through main', async () => {
|
||||
const appLock = shell();
|
||||
const raw = await createVault(PIN);
|
||||
expect(appLock.wrap).toHaveBeenCalledTimes(1);
|
||||
expect((await readVault())!.pin.shell).toBe(true);
|
||||
expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw));
|
||||
expect(appLock.unwrap).toHaveBeenCalledTimes(1);
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
await expect(openVaultWithPin(PIN)).rejects.toMatchObject({ name: 'VaultShellError' });
|
||||
});
|
||||
|
||||
it('main saying no isn\'t a wrong PIN, and costs no try', async () => {
|
||||
shell();
|
||||
await createVault(PIN);
|
||||
shell({ unwrap: vi.fn(async () => null) });
|
||||
for (let i = 0; i < FREE_FAILURES + 2; i += 1) {
|
||||
await expect(openVaultWithPin(PIN)).rejects.toMatchObject({ name: 'VaultShellError' });
|
||||
}
|
||||
shell();
|
||||
await expect(openVaultWithPin(PIN)).resolves.toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it('a changed PIN goes through main too', async () => {
|
||||
const appLock = shell();
|
||||
await createVault(PIN);
|
||||
await changeVaultPin(PIN, 'next-pin-5521');
|
||||
expect(appLock.wrap).toHaveBeenCalledTimes(2);
|
||||
expect((await readVault())!.pin.shell).toBe(true);
|
||||
await expect(openVaultWithPin('next-pin-5521')).resolves.toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it('a shell whose storage only obfuscates: no wrap, and it says so in the record', async () => {
|
||||
const appLock = shell({ status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false, wrap: false })) });
|
||||
await createVault(PIN);
|
||||
expect(appLock.wrap).not.toHaveBeenCalled();
|
||||
expect((await readVault())!.pin.shell).toBeUndefined();
|
||||
});
|
||||
|
||||
it('with no shell at all, a browser: nothing changes', async () => {
|
||||
await createVault(PIN);
|
||||
expect((await readVault())!.pin.shell).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrong PINs', () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
async function failTimes(count: number, now = NOW): Promise<void> {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await openVaultWithPin('wrong-pin', now + i).catch((error) => {
|
||||
if ((error as Error).name !== 'WrongPinError') throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('cost nothing at first, then a growing wait, up to a long one and no wipe', () => {
|
||||
expect(waitAfter(FREE_FAILURES)).toBe(0);
|
||||
expect(waitAfter(FREE_FAILURES + 1)).toBe(30_000);
|
||||
expect(waitAfter(FREE_FAILURES + 2)).toBe(60_000);
|
||||
expect(waitAfter(1000)).toBe(LONGEST_WAIT_MS);
|
||||
});
|
||||
|
||||
it('block the next try even with the right PIN', async () => {
|
||||
await createVault(PIN);
|
||||
await failTimes(FREE_FAILURES + 1);
|
||||
await expect(openVaultWithPin(PIN, NOW + FREE_FAILURES + 2)).rejects.toBeInstanceOf(PinBlockedError);
|
||||
});
|
||||
|
||||
it('are still counted after a reload', async () => {
|
||||
await createVault(PIN);
|
||||
await failTimes(FREE_FAILURES + 1);
|
||||
vi.resetModules();
|
||||
const fresh = await import('../vault');
|
||||
await expect(fresh.openVaultWithPin(PIN, NOW + 100)).rejects.toMatchObject({ name: 'PinBlockedError' });
|
||||
expect(await fresh.pinBlockedUntil(NOW + 100)).toBeGreaterThan(NOW + 100);
|
||||
});
|
||||
|
||||
it('are counted before the check, so leaving during one gains nothing', async () => {
|
||||
await createVault(PIN);
|
||||
await failTimes(FREE_FAILURES);
|
||||
// A check that never finishes: the page was closed while PBKDF2 ran.
|
||||
const derive = vi.spyOn(crypto.subtle, 'deriveKey').mockImplementationOnce(() => new Promise(() => {}));
|
||||
void openVaultWithPin('wrong-pin', NOW + 50);
|
||||
await vi.waitFor(() => expect(derive).toHaveBeenCalled());
|
||||
derive.mockRestore();
|
||||
expect(await pinBlockedUntil(NOW + 51)).toBeGreaterThan(NOW + 51);
|
||||
});
|
||||
|
||||
it('are forgotten after the right PIN', async () => {
|
||||
await createVault(PIN);
|
||||
await failTimes(FREE_FAILURES);
|
||||
await openVaultWithPin(PIN, NOW + 100);
|
||||
expect(await pinBlockedUntil(NOW + 200)).toBe(0);
|
||||
await failTimes(FREE_FAILURES, NOW + 200);
|
||||
expect(await pinBlockedUntil(NOW + 300)).toBe(0);
|
||||
});
|
||||
|
||||
it('count again after the wait, for longer', async () => {
|
||||
await createVault(PIN);
|
||||
await failTimes(FREE_FAILURES + 1);
|
||||
const later = NOW + 31_000;
|
||||
await expect(openVaultWithPin('wrong-pin', later)).rejects.toBeInstanceOf(WrongPinError);
|
||||
expect(await pinBlockedUntil(later)).toBe(later + 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the account keys under the lock', () => {
|
||||
it('can\'t be read from disk without the lock key, and read back the same with it', async () => {
|
||||
const { keyHex } = await openAccountKey('acc-layer');
|
||||
const raw = await createVault(PIN);
|
||||
const layer = lockLayer(await importLockKey(raw));
|
||||
await rewrapAccountKey('acc-layer', [layer], [layer]);
|
||||
|
||||
const record = await getAccountRecord('acc-layer');
|
||||
expect(record?.layers).toEqual(['applock']);
|
||||
expect(hex(record!.wrapped)).not.toContain(keyHex);
|
||||
await expect(openAccountKey('acc-layer')).rejects.toBeInstanceOf(StoreKeyLayerMissingError);
|
||||
|
||||
const other = lockLayer(await importLockKey(new Uint8Array(32).fill(9)));
|
||||
await expect(openAccountKey('acc-layer', [other])).rejects.toBeDefined();
|
||||
|
||||
expect((await openAccountKey('acc-layer', [layer])).keyHex).toBe(keyHex);
|
||||
});
|
||||
|
||||
it('come off the layer again without changing the database key', async () => {
|
||||
const { keyHex } = await openAccountKey('acc-off');
|
||||
const raw = await createVault(PIN);
|
||||
const layer = lockLayer(await importLockKey(raw));
|
||||
await rewrapAccountKey('acc-off', [layer], [layer]);
|
||||
await rewrapAccountKey('acc-off', [layer], []);
|
||||
expect((await getAccountRecord('acc-off'))?.layers).toEqual([]);
|
||||
expect((await openAccountKey('acc-off')).keyHex).toBe(keyHex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a draft a locking window couldn\'t save', () => {
|
||||
it('can be sealed with no key, and only the lock key opens it', async () => {
|
||||
const raw = await createVault(PIN);
|
||||
const key = await importLockKey(raw);
|
||||
await sealDraft({ subject: 'Secret plan' });
|
||||
await expect(openSealedDrafts(await importLockKey(new Uint8Array(32).fill(1)))).rejects.toBeDefined();
|
||||
expect(await openSealedDrafts(key)).toEqual([{ subject: 'Secret plan' }]);
|
||||
expect(await openSealedDrafts(key)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the proof for the desktop app', () => {
|
||||
it('follows from the lock key and isn\'t the lock key', async () => {
|
||||
const raw = new Uint8Array(32).fill(3);
|
||||
const proof = await mainProof(raw);
|
||||
expect(hex(proof)).not.toBe(hex(raw));
|
||||
expect(hex(await mainProof(raw))).toBe(hex(proof));
|
||||
expect(hex(await mainProof(new Uint8Array(32).fill(4)))).not.toBe(hex(proof));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,978 @@
|
||||
/**
|
||||
* App lock in one tab: when it locks, what locking does, what unlocking does.
|
||||
*
|
||||
* Locking saves what's being written, closes the store, drops the keys, then reloads
|
||||
* the page. The reload is how memory gets cleared: no list of stores to reset can
|
||||
* be as complete as a new document, and it takes the plugin frames, the client's
|
||||
* credentials and every module cache along. The new document stops at the lock screen
|
||||
* (components/app-lock/app-lock-gate.tsx), so nothing starts until someone unlocks.
|
||||
*
|
||||
* The lock key lives in the memory of the tab that unlocked, and in no other. It
|
||||
* never goes over a channel. That tab holds a Web Lock for as long as it lives. When it
|
||||
* goes, whichever tab inherits the store couldn't open it, so every tab locks.
|
||||
*
|
||||
* In the desktop app, main has the last word on whether the app is locked (see
|
||||
* the shell's src/main/app-lock-gate.ts). A page can tell it to lock and can prove
|
||||
* the real unlock, and has no way to just say "unlocked".
|
||||
*/
|
||||
|
||||
import { flushComposers, composeSnapshots } from '@/lib/compose-flush-registry';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { setAppLockCheck } from '@/lib/desktop/app-lock-state';
|
||||
import { restoreUrlFor } from '@/lib/desktop/window-restore';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { keepRescuedDraft } from '@/lib/popout/rescued-drafts';
|
||||
import { closePopoutWindow, isPopoutWindow } from '@/lib/popout/window-role';
|
||||
import { getStore, type StoreBackend } from '@/lib/store/client/api';
|
||||
import { forgetAccountKey, listAccountRecords, rewrapAccountKey, StoreKeyLayerMissingError, type AccountRecord, type WrapLayer } from '@/lib/store/client/keys';
|
||||
import { purgeStoreFiles } from '@/lib/store/client/purge';
|
||||
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
|
||||
import { OWNER_LOCK } from '@/lib/store/worker/protocol';
|
||||
import type { DesktopOsUnlockAnswer } from '@/lib/desktop/types';
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import { importLockKey, LOCK_LAYER, lockLayer, mainProof, SHELL_LAYER } from './crypto';
|
||||
import { watchIdle } from './idle';
|
||||
import { createPasskey, passkeyPrf, PasskeyNoPrfError } from './passkey';
|
||||
import { appLockRequired, effectiveTimeout, onLockPolicyChange } from './policy';
|
||||
import { reloadSealedStores, restoreSealedStorage, scrubBrowserStorage } from './scrub';
|
||||
import { clearPendingCookies, markCookiesToClear, rememberMainProof, requireFreshLogin } from './sign-out';
|
||||
import {
|
||||
APP_LOCK_CHANNEL,
|
||||
APP_LOCK_HOLDER,
|
||||
APP_LOCK_KEY,
|
||||
isAppLockedNow,
|
||||
postAppLockMessage,
|
||||
readAppLockFile,
|
||||
setLockedHere,
|
||||
writeAppLockFile,
|
||||
type AppLockFile,
|
||||
type AppLockMessage,
|
||||
} from './state';
|
||||
import {
|
||||
addVaultPrf,
|
||||
changeVaultPin,
|
||||
createVault,
|
||||
deleteVault,
|
||||
openSealedDrafts,
|
||||
openVaultWithPin,
|
||||
openVaultWithPrf,
|
||||
readVault,
|
||||
removeVaultPrf,
|
||||
sealDraft,
|
||||
vaultExists,
|
||||
} from './vault';
|
||||
|
||||
export type AppLockPhase =
|
||||
/** Off, or on and unlocked. */
|
||||
| 'open'
|
||||
/** On, and this tab asks whether another tab holds the key. */
|
||||
| 'checking'
|
||||
/** An organisation requires it and no PIN exists yet. */
|
||||
| 'setup'
|
||||
/** On its way to the lock screen: saving, closing. */
|
||||
| 'locking'
|
||||
| 'locked'
|
||||
/** Locked, in a page that couldn't reload: the app is gone from the screen. */
|
||||
| 'locked-stay';
|
||||
|
||||
/** Long enough for a draft save, and for a send under way to end. The screen is covered by then. */
|
||||
const LOCK_FLUSH_MS = 15_000;
|
||||
const STORE_CLOSE_MS = 5_000;
|
||||
const OWNER_WAIT_MS = 5_000;
|
||||
const HOLDER_WAIT_MS = 1_500;
|
||||
const RELOAD_PROBE_MS = 2_000;
|
||||
/** How long a start without the lock file holds the page for the look at the vault. */
|
||||
const VAULT_LOOK_MS = 1_500;
|
||||
/** Locked on a page that couldn't reload: how often it tries again. Only the server may have been down. */
|
||||
export const STAY_RETRY_MS = 30_000;
|
||||
/** How long a forgotten PIN waits for the sign-out, on top of the cookies' own wait. Nothing depends on it by then. */
|
||||
const FORGET_LOGOUT_MS = 5_000;
|
||||
const DRAFTS_SAVED_KEY = 'bulwark-app-lock-drafts-saved';
|
||||
|
||||
let booted = false;
|
||||
let phase: AppLockPhase = 'open';
|
||||
const listeners = new Set<() => void>();
|
||||
let hold: { promise: Promise<void>; release: () => void } | null = null;
|
||||
|
||||
/** Only ever in this tab's memory. Can't be read back out. */
|
||||
let heldKey: CryptoKey | null = null;
|
||||
let releaseHolder: (() => void) | null = null;
|
||||
let holderWatch: AbortController | null = null;
|
||||
let stopIdle: (() => void) | null = null;
|
||||
let stopStay: (() => void) | null = null;
|
||||
/** Setting up right now: the vault this tab just wrote says nothing about a lost file. */
|
||||
let enabling = false;
|
||||
/** Main's wrap for the keys, when it has a real one. Known once asked: unlocking and enabling ask first. */
|
||||
let shellWraps = false;
|
||||
|
||||
/**
|
||||
* The two calls a same-origin frame mustn't reach: a frame can get at the parent's bridge, and
|
||||
* main can't tell those calls apart (the shell's ipc.ts). The preload hands them out once per
|
||||
* document, and this takes them in the first render, before any frame exists.
|
||||
*/
|
||||
let controls: { lock: () => void; osUnlock: () => Promise<DesktopOsUnlockAnswer> } | null | undefined;
|
||||
|
||||
function desktopControls() {
|
||||
if (controls === undefined) controls = getDesktopBridge()?.appLock?.claim() ?? null;
|
||||
return controls;
|
||||
}
|
||||
|
||||
function setPhase(next: AppLockPhase): void {
|
||||
if (phase === next) return;
|
||||
phase = next;
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
export function getAppLockPhase(): AppLockPhase {
|
||||
return phase;
|
||||
}
|
||||
|
||||
export function subscribeAppLockPhase(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function newHold(): { promise: Promise<void>; release: () => void } {
|
||||
let release = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
/** While this is pending, the app under the gate doesn't hydrate. */
|
||||
export function appLockHold(): Promise<void> | null {
|
||||
return hold?.promise ?? null;
|
||||
}
|
||||
|
||||
function markDocument(locked: boolean): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (locked) document.documentElement.setAttribute('data-app-locked', '');
|
||||
else document.documentElement.removeAttribute('data-app-locked');
|
||||
}
|
||||
|
||||
function hasAccounts(): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem('account-registry') ?? 'null') as { state?: { accounts?: unknown[] } } | null;
|
||||
return (parsed?.state?.accounts?.length ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setupNeeded(): boolean {
|
||||
return !readAppLockFile().on && appLockRequired() && hasAccounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides this tab's start, synchronously, in the first client render. A tab that
|
||||
* finds the lock on doesn't start the app until it knows a tab holds the key.
|
||||
*/
|
||||
export function bootAppLock(): void {
|
||||
if (booted || typeof window === 'undefined' || IS_LITE) return;
|
||||
booted = true;
|
||||
setAppLockCheck(isAppLockedNow);
|
||||
desktopControls();
|
||||
const file = readAppLockFile();
|
||||
if (file.on) {
|
||||
setLockedHere(true);
|
||||
hold = newHold();
|
||||
if (file.locked) {
|
||||
phase = 'locked';
|
||||
tellDesktopLocked();
|
||||
void scrubBrowserStorage();
|
||||
} else {
|
||||
phase = 'checking';
|
||||
void settleCheck();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDocument(false);
|
||||
hold = newHold();
|
||||
if (setupNeeded()) {
|
||||
phase = 'setup';
|
||||
} else {
|
||||
setLockedHere(true);
|
||||
phase = 'checking';
|
||||
}
|
||||
void settleNoFile();
|
||||
}
|
||||
|
||||
function lockedFromNow(): void {
|
||||
writeAppLockFile({ ...readAppLockFile(), on: true, locked: true });
|
||||
setLockedHere(true);
|
||||
markDocument(true);
|
||||
hold ??= newHold();
|
||||
tellDesktopLocked();
|
||||
tellServiceWorker('locked');
|
||||
void scrubBrowserStorage();
|
||||
setPhase('locked');
|
||||
}
|
||||
|
||||
/**
|
||||
* No file says the lock is on. Most often that's the truth, and this takes one look at
|
||||
* IndexedDB. But the file can go on its own (a cleared site setting, an extension, someone
|
||||
* at the keyboard), and the vault is what really decides: it holds the key the store's
|
||||
* keys depend on. A look that takes too long lets the page go on, and locks it if it ends in a vault.
|
||||
*/
|
||||
async function settleNoFile(): Promise<void> {
|
||||
const looking = vaultExists();
|
||||
let waited = false;
|
||||
const found = await Promise.race([
|
||||
looking,
|
||||
new Promise<boolean>((resolve) => setTimeout(() => {
|
||||
waited = true;
|
||||
resolve(false);
|
||||
}, VAULT_LOOK_MS)),
|
||||
]);
|
||||
if (readAppLockFile().on) {
|
||||
// Another tab turned it on meanwhile.
|
||||
if (phase === 'checking') void settleCheck();
|
||||
return;
|
||||
}
|
||||
if (found && !enabling) {
|
||||
lockedFromNow();
|
||||
return;
|
||||
}
|
||||
if (phase === 'checking') {
|
||||
setLockedHere(false);
|
||||
const next = setupNeeded() ? 'setup' : 'open';
|
||||
setPhase(next);
|
||||
if (next === 'open') {
|
||||
hold?.release();
|
||||
hold = null;
|
||||
}
|
||||
}
|
||||
if (waited) {
|
||||
void looking.then((late) => {
|
||||
if (late && !enabling && !readAppLockFile().on) void lockLate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function lockLate(): Promise<void> {
|
||||
writeAppLockFile({ ...readAppLockFile(), on: true, locked: true });
|
||||
postAppLockMessage({ type: 'lock' });
|
||||
await applyLockHere();
|
||||
}
|
||||
|
||||
async function holderAlive(): Promise<boolean> {
|
||||
if (!navigator.locks?.query) return false;
|
||||
try {
|
||||
const { held } = await navigator.locks.query();
|
||||
return (held ?? []).some((lock) => lock.name === APP_LOCK_HOLDER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function desktopSaysLocked(): Promise<boolean> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!member) return false;
|
||||
try {
|
||||
return (await member.status()).locked;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function settleCheck(): Promise<void> {
|
||||
if (phase !== 'checking') return;
|
||||
if (await holderAlive() && !(await desktopSaysLocked())) {
|
||||
openUp();
|
||||
return;
|
||||
}
|
||||
// Nobody holds the key: a crash, or the browser was closed without saying so.
|
||||
lockedFromNow();
|
||||
}
|
||||
|
||||
/** Main's gate, and the OS integration's own state: tray, jump list and toasts (the shell's os-hub.ts). */
|
||||
function tellDesktopLocked(): void {
|
||||
desktopControls()?.lock();
|
||||
getDesktopBridge()?.os?.setLocked(true);
|
||||
}
|
||||
|
||||
/** The service worker shows push notifications with no page around. Kept across its restarts. */
|
||||
function tellServiceWorker(state: 'locked' | 'unlocked' | 'off'): void {
|
||||
try {
|
||||
const message = { type: 'app-lock', state };
|
||||
const worker = navigator.serviceWorker;
|
||||
if (!worker) return;
|
||||
if (worker.controller) worker.controller.postMessage(message);
|
||||
else void worker.ready.then((registration) => registration.active?.postMessage(message));
|
||||
} catch {
|
||||
// No service worker, no push notifications.
|
||||
}
|
||||
}
|
||||
|
||||
function startIdle(): void {
|
||||
stopIdle?.();
|
||||
const file = readAppLockFile();
|
||||
if (!file.on) {
|
||||
stopIdle = null;
|
||||
return;
|
||||
}
|
||||
stopIdle = watchIdle({
|
||||
timeoutMs: () => effectiveTimeout(readAppLockFile().timeoutMinutes).minutes * 60_000,
|
||||
onIdle: () => lockEverywhere(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab with no key watches for the one that has it to go. The request waits as long
|
||||
* as the holder lives. Being granted means it's gone.
|
||||
*/
|
||||
function watchHolder(): void {
|
||||
holderWatch?.abort();
|
||||
holderWatch = null;
|
||||
if (!navigator.locks?.request || releaseHolder) return;
|
||||
const watch = new AbortController();
|
||||
holderWatch = watch;
|
||||
void navigator.locks.request(APP_LOCK_HOLDER, { signal: watch.signal }, () => {
|
||||
if (heldKey) return keepHolder();
|
||||
if (readAppLockFile().on && !readAppLockFile().locked) lockEverywhere();
|
||||
return undefined;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function keepHolder(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseHolder = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async function becomeHolder(): Promise<void> {
|
||||
holderWatch?.abort();
|
||||
holderWatch = null;
|
||||
if (releaseHolder || !navigator.locks?.request) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, HOLDER_WAIT_MS);
|
||||
void navigator.locks.request(APP_LOCK_HOLDER, () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
return keepHolder();
|
||||
}).catch(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function dropKey(): void {
|
||||
heldKey = null;
|
||||
rememberMainProof(null);
|
||||
releaseHolder?.();
|
||||
releaseHolder = null;
|
||||
}
|
||||
|
||||
/** The app starts, or goes on, in this tab. */
|
||||
function openUp(): void {
|
||||
stopStay?.();
|
||||
setLockedHere(false);
|
||||
markDocument(false);
|
||||
setPhase('open');
|
||||
startIdle();
|
||||
if (readAppLockFile().on && !heldKey) watchHolder();
|
||||
hold?.release();
|
||||
hold = null;
|
||||
}
|
||||
|
||||
// ---- Locking ----
|
||||
|
||||
/** Every window and tab. Safe to call at any time; it only ever makes things more locked. */
|
||||
export function lockEverywhere(): void {
|
||||
const file = readAppLockFile();
|
||||
if (!file.on) return;
|
||||
writeAppLockFile({ ...file, locked: true });
|
||||
postAppLockMessage({ type: 'lock' });
|
||||
void applyLockHere();
|
||||
}
|
||||
|
||||
async function saveComposers(): Promise<{ closing: boolean; saved: number }> {
|
||||
const snapshots = composeSnapshots();
|
||||
const flushed = await flushComposers(LOCK_FLUSH_MS);
|
||||
const after = composeSnapshots();
|
||||
let closing = false;
|
||||
let saved = 0;
|
||||
const trouble = flushed.failed > 0 || flushed.timedOut;
|
||||
for (const snapshot of after) {
|
||||
const state = snapshot.state();
|
||||
const draft = snapshot.draft();
|
||||
if (trouble && state) {
|
||||
// Not on the server. Sealed, it waits for the lock key.
|
||||
await sealDraft(state).catch(() => {});
|
||||
if (snapshot.ownWindow) closing = true;
|
||||
continue;
|
||||
}
|
||||
if (draft) saved += 1;
|
||||
if (!snapshot.ownWindow) continue;
|
||||
if (draft) {
|
||||
// This window's address names no text of its own after a reload: it opens this draft.
|
||||
window.history.replaceState(window.history.state, '', restoreUrlFor(draft));
|
||||
} else {
|
||||
closing = true;
|
||||
}
|
||||
}
|
||||
if (snapshots.length > 0 && saved > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(DRAFTS_SAVED_KEY, '1');
|
||||
} catch {
|
||||
// Only the notice goes.
|
||||
}
|
||||
}
|
||||
return { closing, saved };
|
||||
}
|
||||
|
||||
/** A flush under way stops at its next item (lib/offline/flush.ts). This waits for that item. */
|
||||
async function outboxAtRest(): Promise<void> {
|
||||
if (!navigator.locks?.query) return;
|
||||
const until = Date.now() + LOCK_FLUSH_MS;
|
||||
while (Date.now() < until) {
|
||||
const { held } = await navigator.locks.query();
|
||||
if (!(held ?? []).some((lock) => lock.name?.startsWith('bulwark-offline-flush:'))) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
let reloadSeam: (() => void) | null = null;
|
||||
|
||||
function reloadPage(): void {
|
||||
if (reloadSeam) reloadSeam();
|
||||
else window.location.reload();
|
||||
}
|
||||
|
||||
/** Tests only: jsdom can't reload. */
|
||||
export function setReloadForTests(run: (() => void) | null): void {
|
||||
reloadSeam = run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reload would land on a page. The service worker answers this as it would answer
|
||||
* the reload (public/sw.js), from its copy of the shell when offline, so a lock with no
|
||||
* network still clears memory. Only a page with neither stays where it is.
|
||||
*/
|
||||
async function pageReachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: { 'x-bulwark-reload-probe': '1' },
|
||||
signal: AbortSignal.timeout(RELOAD_PROBE_MS),
|
||||
});
|
||||
void response.body?.cancel().catch(() => {});
|
||||
return response.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Off the screen now, out of memory as soon as a reload can land somewhere. */
|
||||
function stayUntilReachable(): void {
|
||||
setPhase('locked-stay');
|
||||
const tryNow = () => {
|
||||
void pageReachable().then((reachable) => {
|
||||
if (reachable && isAppLockedNow() && phase === 'locked-stay') {
|
||||
stopStay?.();
|
||||
reloadPage();
|
||||
}
|
||||
});
|
||||
};
|
||||
const timer = setInterval(tryNow, STAY_RETRY_MS);
|
||||
window.addEventListener('online', tryNow);
|
||||
stopStay = () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('online', tryNow);
|
||||
stopStay = null;
|
||||
};
|
||||
}
|
||||
|
||||
async function applyLockHere(): Promise<void> {
|
||||
if (phase === 'locking' || phase === 'locked' || phase === 'locked-stay') return;
|
||||
const wasRunning = phase === 'open';
|
||||
setLockedHere(true);
|
||||
markDocument(true);
|
||||
setPhase('locking');
|
||||
tellDesktopLocked();
|
||||
tellServiceWorker('locked');
|
||||
stopIdle?.();
|
||||
stopIdle = null;
|
||||
holderWatch?.abort();
|
||||
holderWatch = null;
|
||||
|
||||
let closing = false;
|
||||
if (wasRunning) {
|
||||
try {
|
||||
({ closing } = await saveComposers());
|
||||
await outboxAtRest();
|
||||
} catch {
|
||||
// The lock goes on: what's on screen is covered either way.
|
||||
}
|
||||
await scrubBrowserStorage();
|
||||
}
|
||||
await lockStore();
|
||||
dropKey();
|
||||
if (closing) closePopoutWindow();
|
||||
if (await pageReachable()) {
|
||||
reloadPage();
|
||||
return;
|
||||
}
|
||||
// The server is out of reach and there's no copy of the page: a reload would land on the
|
||||
// browser's error page, with no way to unlock.
|
||||
stayUntilReachable();
|
||||
}
|
||||
|
||||
function storeBackend(): StoreBackend {
|
||||
return getStore();
|
||||
}
|
||||
|
||||
async function lockStore(): Promise<void> {
|
||||
try {
|
||||
await storeBackend().lock?.(STORE_CLOSE_MS);
|
||||
} catch {
|
||||
// Its worker is stopped either way.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Unlocking ----
|
||||
|
||||
/**
|
||||
* Main has to agree before this tab opens: a page can't go on while main says locked. A no
|
||||
* on a good PIN means main lost its record or keeps one from before. Having the proof shows
|
||||
* the PIN was right, so it enrolls again. Main refuses that while it holds another lock, and
|
||||
* then only the user can let that one go, natively.
|
||||
*/
|
||||
async function proveToMain(proof: Uint8Array): Promise<void> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!member) return;
|
||||
if (await member.prove(proof).catch(() => false)) return;
|
||||
if (await member.enroll(proof).catch(() => false)) return;
|
||||
if (await member.forget().catch(() => false) && await member.enroll(proof).catch(() => false)) return;
|
||||
throw new Error('The desktop app stays locked');
|
||||
}
|
||||
|
||||
async function askShell(): Promise<void> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
shellWraps = member?.wrap ? Boolean((await member.status().catch(() => null))?.wrap) : false;
|
||||
}
|
||||
|
||||
async function holdAndOpen(raw: Uint8Array): Promise<void> {
|
||||
try {
|
||||
const proof = await mainProof(raw);
|
||||
const key = await importLockKey(raw);
|
||||
await proveToMain(proof);
|
||||
rememberMainProof(proof);
|
||||
heldKey = key;
|
||||
} finally {
|
||||
raw.fill(0);
|
||||
}
|
||||
await askShell();
|
||||
await becomeHolder();
|
||||
const key = heldKey!;
|
||||
await addLayerWhereMissing(key);
|
||||
await storeBackend().unlock?.(OWNER_WAIT_MS);
|
||||
// Before the app below starts, and before the others hear: they read it again when they do.
|
||||
if (!isPopoutWindow()) {
|
||||
await restoreSealedStorage(key);
|
||||
await reloadSealedStores();
|
||||
}
|
||||
writeAppLockFile({ ...readAppLockFile(), on: true, locked: false });
|
||||
postAppLockMessage({ type: 'unlocked' });
|
||||
tellServiceWorker('unlocked');
|
||||
openUp();
|
||||
await deliverSealedDrafts(key);
|
||||
}
|
||||
|
||||
/** A pop-out is about to close and nothing listens for it with no main window: they wait for one. */
|
||||
async function deliverSealedDrafts(key: CryptoKey): Promise<void> {
|
||||
if (isPopoutWindow()) return;
|
||||
try {
|
||||
for (const state of await openSealedDrafts(key)) keepRescuedDraft(state as ComposerDraftData);
|
||||
} catch {
|
||||
// They stay sealed for the next unlock.
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlockWithPin(pin: string): Promise<void> {
|
||||
await holdAndOpen(await openVaultWithPin(pin));
|
||||
}
|
||||
|
||||
export async function unlockWithPasskey(): Promise<void> {
|
||||
const vault = await readVault();
|
||||
if (!vault?.prf) throw new PasskeyNoPrfError();
|
||||
const output = await passkeyPrf(vault.prf.credentialId, vault.prf.salt);
|
||||
if (!output) throw new PasskeyNoPrfError();
|
||||
await holdAndOpen(await openVaultWithPrf(output));
|
||||
}
|
||||
|
||||
/** Windows Hello or Touch ID. Main asks, and only hands over the key after a yes. */
|
||||
export async function unlockWithOs(): Promise<boolean> {
|
||||
const member = desktopControls();
|
||||
if (!member) return false;
|
||||
const answer = await member.osUnlock();
|
||||
if (!answer.ok) return false;
|
||||
await holdAndOpen(new Uint8Array(answer.key));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- The account keys ----
|
||||
|
||||
/** A store record made when the lock was off, or before a crash: it gets the layer now. */
|
||||
async function addLayerWhereMissing(key: CryptoKey): Promise<void> {
|
||||
const layer = lockLayer(key);
|
||||
const shell = shellLayer();
|
||||
for (const record of await listAccountRecords().catch(() => [] as AccountRecord[])) {
|
||||
if (record.layers.includes(LOCK_LAYER)) continue;
|
||||
// Whatever wraps it already has to come off to get at the key, and goes back on under the lock.
|
||||
await rewrapAccountKey(record.account, shell ? [shell] : [], shell ? [shell, layer] : [layer]).catch((error: unknown) => {
|
||||
// One account's record that can't open here (a copied profile) doesn't keep the others shut.
|
||||
console.warn('[app-lock] A record stays without the layer:', (error as Error)?.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Off again, and main's wrap with it: with the lock off nothing asks main, and a start must not need it. */
|
||||
async function removeLayerEverywhere(key: CryptoKey): Promise<void> {
|
||||
const layer = lockLayer(key);
|
||||
const shell = shellLayer();
|
||||
for (const record of await listAccountRecords().catch(() => [] as AccountRecord[])) {
|
||||
if (record.layers.length > 0) await rewrapAccountKey(record.account, shell ? [shell, layer] : [layer], []);
|
||||
}
|
||||
}
|
||||
|
||||
/** The desktop's safeStorage around the database key (the build prompt's second layer), while the lock is on. */
|
||||
function shellLayer(): WrapLayer | null {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!shellWraps || !member?.wrap || !member.unwrap) return null;
|
||||
const through = async (run: (data: Uint8Array) => Promise<Uint8Array | null>, data: Uint8Array) => {
|
||||
const out = await run(data);
|
||||
if (!out) throw new StoreKeyLayerMissingError(SHELL_LAYER);
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
name: SHELL_LAYER,
|
||||
wrap: (data) => through(member.wrap, data),
|
||||
unwrap: (data) => through(member.unwrap, data),
|
||||
};
|
||||
}
|
||||
|
||||
/** What the store's key handling uses. Throws while this tab has no key, so nothing opens or gets written unwrapped. */
|
||||
export function appLockKeyLayers(): readonly WrapLayer[] {
|
||||
if (!readAppLockFile().on) return [];
|
||||
if (!heldKey) throw new StoreKeyLayerMissingError(LOCK_LAYER);
|
||||
const shell = shellLayer();
|
||||
return shell ? [shell, lockLayer(heldKey)] : [lockLayer(heldKey)];
|
||||
}
|
||||
|
||||
/** This tab inherited the store and has no key for it. */
|
||||
export function onStoreKeyMissing(): void {
|
||||
lockEverywhere();
|
||||
}
|
||||
|
||||
async function ownerIsElsewhere(): Promise<boolean> {
|
||||
if (!navigator.locks?.query) return false;
|
||||
const { held } = await navigator.locks.query();
|
||||
return (held ?? []).some((lock) => lock.name === OWNER_LOCK) && !storeBackend().ownsStoreHere?.();
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
export async function enableAppLock(pin: string): Promise<void> {
|
||||
enabling = true;
|
||||
try {
|
||||
await enable(pin);
|
||||
} finally {
|
||||
enabling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enable(pin: string): Promise<void> {
|
||||
const raw = await createVault(pin);
|
||||
const proof = await mainProof(raw);
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (member && !(await member.enroll(proof))) {
|
||||
// Main still knows an earlier lock this profile lost. It asks the user natively.
|
||||
if (!(await member.forget()) || !(await member.enroll(proof))) {
|
||||
raw.fill(0);
|
||||
await deleteVault();
|
||||
throw new Error('The desktop app refused the lock');
|
||||
}
|
||||
}
|
||||
const key = await importLockKey(raw);
|
||||
raw.fill(0);
|
||||
heldKey = key;
|
||||
rememberMainProof(proof);
|
||||
await askShell();
|
||||
await becomeHolder();
|
||||
await addLayerWhereMissing(key);
|
||||
const file: AppLockFile = { ...readAppLockFile(), on: true, locked: false };
|
||||
writeAppLockFile(file);
|
||||
try {
|
||||
localStorage.removeItem('email-snapshot');
|
||||
} catch {
|
||||
// Storage blocked: there's no snapshot either.
|
||||
}
|
||||
postAppLockMessage({ type: 'enabled' });
|
||||
tellServiceWorker('unlocked');
|
||||
openUp();
|
||||
// Another tab has the store open and no key for what comes next. Everyone starts over.
|
||||
if (await ownerIsElsewhere()) lockEverywhere();
|
||||
}
|
||||
|
||||
/** Off again. The PIN proves it's the owner of the lock, in this tab or any other. */
|
||||
export async function disableAppLock(pin: string): Promise<void> {
|
||||
if (appLockRequired()) throw new Error('The organisation requires the app lock');
|
||||
const raw = await openVaultWithPin(pin);
|
||||
let key: CryptoKey;
|
||||
try {
|
||||
const proof = await mainProof(raw);
|
||||
await letMainGo(proof);
|
||||
key = await importLockKey(raw);
|
||||
} finally {
|
||||
raw.fill(0);
|
||||
}
|
||||
await askShell();
|
||||
await removeLayerEverywhere(key);
|
||||
await deleteVault();
|
||||
writeAppLockFile({ on: false, locked: false });
|
||||
postAppLockMessage({ type: 'off' });
|
||||
tellServiceWorker('off');
|
||||
afterOff();
|
||||
}
|
||||
|
||||
/** A no means main keeps a lock from before this PIN. Only the user can let that go, natively. */
|
||||
async function letMainGo(proof: Uint8Array): Promise<void> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!member || await member.disable(proof).catch(() => false)) return;
|
||||
const status = await member.status().catch(() => null);
|
||||
if (status && !status.enrolled) return;
|
||||
if (!(await member.forget().catch(() => false))) throw new Error('The desktop app keeps the lock');
|
||||
}
|
||||
|
||||
function afterOff(): void {
|
||||
stopStay?.();
|
||||
stopIdle?.();
|
||||
stopIdle = null;
|
||||
holderWatch?.abort();
|
||||
holderWatch = null;
|
||||
dropKey();
|
||||
setLockedHere(false);
|
||||
markDocument(false);
|
||||
setPhase('open');
|
||||
hold?.release();
|
||||
hold = null;
|
||||
}
|
||||
|
||||
export function changeAppLockPin(currentPin: string, newPin: string): Promise<void> {
|
||||
return changeVaultPin(currentPin, newPin);
|
||||
}
|
||||
|
||||
export function setAppLockTimeout(minutes: number): void {
|
||||
writeAppLockFile({ ...readAppLockFile(), timeoutMinutes: minutes });
|
||||
}
|
||||
|
||||
export async function addPasskey(pin: string, appName: string): Promise<void> {
|
||||
const passkey = await createPasskey(appName);
|
||||
const raw = await openVaultWithPin(pin);
|
||||
try {
|
||||
await addVaultPrf(raw, passkey.credentialId, passkey.salt, passkey.prfOutput);
|
||||
} finally {
|
||||
raw.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function removePasskey(): Promise<void> {
|
||||
return removeVaultPrf();
|
||||
}
|
||||
|
||||
export async function hasPasskey(): Promise<boolean> {
|
||||
return Boolean((await readVault())?.prf);
|
||||
}
|
||||
|
||||
export async function addOsUnlock(pin: string): Promise<boolean> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!member) return false;
|
||||
const raw = await openVaultWithPin(pin);
|
||||
try {
|
||||
return await member.osEnroll(await mainProof(raw), raw);
|
||||
} finally {
|
||||
raw.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
let leaveSeam: (() => void) | null = null;
|
||||
|
||||
/** Tests only: jsdom can't navigate. */
|
||||
export function setLeaveForTests(run: (() => void) | null): void {
|
||||
leaveSeam = run;
|
||||
}
|
||||
|
||||
function leaveForLogin(): void {
|
||||
if (leaveSeam) leaveSeam();
|
||||
else window.location.replace(new URL(process.env.NEXT_PUBLIC_BASE_PATH || '/', window.location.origin).href);
|
||||
}
|
||||
|
||||
/** The keys, for sure: with them gone the store's files can't be read, and no record is left wanting the vault. */
|
||||
async function removeAccountKeys(): Promise<boolean> {
|
||||
await purgeStoreFiles().catch(() => {});
|
||||
const left = await listAccountRecords(true).catch(() => [] as AccountRecord[]);
|
||||
for (const record of left) await forgetAccountKey(record.account).catch(() => {});
|
||||
return left.length === 0 || (await listAccountRecords(true).catch(() => left)).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way out when the PIN is gone: all local data, and every login on this device. The
|
||||
* server keeps everything except what waited in the outbox here.
|
||||
*
|
||||
* In an order that doesn't depend on the server. The registry and the keys go first, with
|
||||
* no network, and the lock stays up meanwhile, so wherever this stops nobody is signed in
|
||||
* without a PIN and no record wants a vault that's gone. The lock lifts last. Only then the
|
||||
* sign-out, which needs the server and may hang: nothing waits for it beyond its time.
|
||||
*
|
||||
* What it leaves: the HttpOnly cookies when the server is out of reach, under a mark the
|
||||
* start-up clears first (lib/app-lock/sign-out.ts). And the identity provider's session,
|
||||
* which stays open: the next login asks the provider for a real one.
|
||||
*/
|
||||
export async function forgetEverything(): Promise<boolean> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
// Native: a page's word alone can't lift the desktop's lock.
|
||||
if (member && !(await member.forget())) return false;
|
||||
markCookiesToClear();
|
||||
requireFreshLogin();
|
||||
try {
|
||||
sessionStorage.setItem('signed_out', 'true');
|
||||
} catch {
|
||||
// Only the login page's automatic redirect goes by it.
|
||||
}
|
||||
const { forgetDeviceRows } = await import('@/lib/offline/device-wipe');
|
||||
await forgetDeviceRows().catch(() => {});
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null } as never);
|
||||
try {
|
||||
localStorage.removeItem('account-registry');
|
||||
} catch {
|
||||
// Storage blocked: nothing was kept there either.
|
||||
}
|
||||
if (!(await removeAccountKeys())) return false;
|
||||
purgeSignedOutData({ keepAppLock: true });
|
||||
|
||||
await deleteVault();
|
||||
writeAppLockFile({ on: false, locked: false });
|
||||
postAppLockMessage({ type: 'off' });
|
||||
tellServiceWorker('off');
|
||||
rememberMainProof(null);
|
||||
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
await Promise.race([
|
||||
Promise.resolve().then(() => useAuthStore.getState().logoutAll({ endProviderSession: false })).catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(resolve, FORGET_LOGOUT_MS)),
|
||||
]);
|
||||
await clearPendingCookies();
|
||||
// This tab stays behind its lock screen until the new page: nothing of the app starts here.
|
||||
leaveForLogin();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A saved draft's notice, once, after the lock that saved it. */
|
||||
export function takeDraftsSavedNotice(): boolean {
|
||||
try {
|
||||
if (sessionStorage.getItem(DRAFTS_SAVED_KEY) === null) return false;
|
||||
sessionStorage.removeItem(DRAFTS_SAVED_KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The tab's life ----
|
||||
|
||||
function onMessage(message: AppLockMessage): void {
|
||||
switch (message.type) {
|
||||
case 'lock':
|
||||
void applyLockHere();
|
||||
break;
|
||||
case 'unlocked':
|
||||
if (phase === 'locked' || phase === 'checking' || phase === 'locked-stay') {
|
||||
void desktopSaysLocked().then(async (locked) => {
|
||||
if (locked) return;
|
||||
await reloadSealedStores();
|
||||
openUp();
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'enabled':
|
||||
if (phase === 'setup') {
|
||||
openUp();
|
||||
break;
|
||||
}
|
||||
startIdle();
|
||||
if (!heldKey) watchHolder();
|
||||
break;
|
||||
case 'off':
|
||||
afterOff();
|
||||
break;
|
||||
case 'main-ping':
|
||||
if (!isPopoutWindow()) postAppLockMessage({ type: 'main-pong' });
|
||||
break;
|
||||
case 'focus-main':
|
||||
if (!isPopoutWindow()) window.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs for as long as the tab does, above the part of the page that the lock holds back. */
|
||||
export function startAppLock(): () => void {
|
||||
if (IS_LITE || typeof window === 'undefined') return () => {};
|
||||
bootAppLock();
|
||||
let channel: BroadcastChannel | null = null;
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
channel = new BroadcastChannel(APP_LOCK_CHANNEL);
|
||||
channel.onmessage = (event: MessageEvent<AppLockMessage>) => onMessage(event.data);
|
||||
}
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== APP_LOCK_KEY) return;
|
||||
const file = readAppLockFile();
|
||||
if (file.on && file.locked && phase === 'open') void applyLockHere();
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
const stopDesktop = getDesktopBridge()?.appLock?.onLockRequest(() => lockEverywhere());
|
||||
const reevaluate = () => {
|
||||
if (phase === 'open' && setupNeeded()) setPhase('setup');
|
||||
};
|
||||
const stopPolicy = onLockPolicyChange(reevaluate);
|
||||
if (phase === 'open' && readAppLockFile().on) {
|
||||
startIdle();
|
||||
if (!heldKey) watchHolder();
|
||||
}
|
||||
if (readAppLockFile().on) tellServiceWorker(isAppLockedNow() ? 'locked' : 'unlocked');
|
||||
return () => {
|
||||
channel?.close();
|
||||
window.removeEventListener('storage', onStorage);
|
||||
stopDesktop?.();
|
||||
stopPolicy();
|
||||
stopIdle?.();
|
||||
stopIdle = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** An account appeared in a tab that started with none: a required lock is due now. */
|
||||
export function recheckAppLockSetup(): void {
|
||||
if (phase === 'open' && setupNeeded()) setPhase('setup');
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function resetAppLockForTests(): void {
|
||||
booted = false;
|
||||
phase = 'open';
|
||||
hold = null;
|
||||
heldKey = null;
|
||||
releaseHolder = null;
|
||||
holderWatch = null;
|
||||
stopIdle?.();
|
||||
stopIdle = null;
|
||||
stopStay?.();
|
||||
controls = undefined;
|
||||
enabling = false;
|
||||
shellWraps = false;
|
||||
rememberMainProof(null);
|
||||
listeners.clear();
|
||||
setLockedHere(false);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The keys behind app lock, WebCrypto only.
|
||||
*
|
||||
* One random lock key wraps every account's database key (a layer in
|
||||
* lib/store/client/keys.ts). The lock key itself is only ever stored wrapped: once
|
||||
* under the PIN, and once for each other way in (a passkey's PRF, the desktop app's
|
||||
* OS storage). So a new account needs no PIN, and a changed PIN touches one record.
|
||||
*/
|
||||
|
||||
import type { WrapLayer } from '@/lib/store/client/keys';
|
||||
|
||||
export const LOCK_LAYER = 'applock';
|
||||
export const SHELL_LAYER = 'safeStorage';
|
||||
/** OWASP's floor for PBKDF2-HMAC-SHA256. A wrong guess costs this much, online or off. */
|
||||
export const PBKDF2_ITERATIONS = 600_000;
|
||||
export const MIN_PIN_LENGTH = 6;
|
||||
|
||||
const AES = { name: 'AES-GCM', length: 256 } as const;
|
||||
const PRF_INFO = new TextEncoder().encode('bulwark-app-lock-prf-v1');
|
||||
const PROOF_INFO = new TextEncoder().encode('bulwark-app-lock-main-proof-v1');
|
||||
|
||||
export interface WrappedBytes {
|
||||
iv: Uint8Array;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
function buffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function randomBytes(length: number): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(length));
|
||||
}
|
||||
|
||||
/** The same PIN typed on another keyboard or OS gives the same bytes. */
|
||||
export function pinBytes(pin: string): Uint8Array {
|
||||
return new TextEncoder().encode(pin.normalize('NFKC'));
|
||||
}
|
||||
|
||||
export function pinLongEnough(pin: string): boolean {
|
||||
return [...pin.normalize('NFKC')].length >= MIN_PIN_LENGTH;
|
||||
}
|
||||
|
||||
/** A few of what people pick most, as the check below reads them. Runs and repeats are found by rule. */
|
||||
const COMMON_PINS = new Set([
|
||||
'password', 'password1', 'password123', 'passwort', 'qwerty', 'qwertz', 'qwerty123', 'qwertyuiop', 'asdfgh', 'yxcvbn', 'zxcvbn',
|
||||
'1q2w3e', '1q2w3e4r', 'q1w2e3', 'abc123', 'a1b2c3', '123abc', 'letmein', 'iloveyou', 'admin123', 'welcome', 'welcome1', 'monkey', 'dragon',
|
||||
'147258', '159753', '258456', '789456', '102030', '101010', '696969', '111222', '121314', '131313', '123321', '112233',
|
||||
]);
|
||||
|
||||
/** Digits go round, as on a keypad: 7890123. */
|
||||
function allSameStep(chars: string[], step: number): boolean {
|
||||
return chars.every((char, index) => {
|
||||
if (index === 0) return true;
|
||||
const distance = char.codePointAt(0)! - chars[index - 1].codePointAt(0)!;
|
||||
const digits = /\d/.test(char) && /\d/.test(chars[index - 1]);
|
||||
return distance === step || (digits && (distance + 10) % 10 === (step + 10) % 10);
|
||||
});
|
||||
}
|
||||
|
||||
/** Refused when a PIN is set. Someone with the disk tries exactly these first, and PBKDF2 can't help with those. */
|
||||
export function pinIsObvious(pin: string): boolean {
|
||||
const chars = [...pin.normalize('NFKC').toLowerCase()];
|
||||
if (COMMON_PINS.has(chars.join(''))) return true;
|
||||
if (allSameStep(chars, 0) || allSameStep(chars, 1) || allSameStep(chars, -1)) return true;
|
||||
// 121212, 123123, abcabc: a short piece over and over.
|
||||
for (let size = 1; size <= 3; size += 1) {
|
||||
if (chars.length >= size * 2 && chars.every((char, index) => char === chars[index % size])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class ObviousPinError extends Error {
|
||||
constructor() {
|
||||
super('That PIN is too easy to guess');
|
||||
this.name = 'ObviousPinError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Slow on purpose. Not extractable: it only ever wraps the lock key. */
|
||||
export async function keyFromPin(pin: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
|
||||
const base = await crypto.subtle.importKey('raw', buffer(pinBytes(pin)), 'PBKDF2', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: buffer(salt), iterations },
|
||||
base,
|
||||
AES,
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
/** A key from what an authenticator's PRF gave: 32 bytes that only it can make again. */
|
||||
export async function keyFromPrf(output: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
|
||||
const bytes = output instanceof Uint8Array ? output : new Uint8Array(output);
|
||||
const base = await crypto.subtle.importKey('raw', buffer(bytes), 'HKDF', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PRF_INFO },
|
||||
base,
|
||||
AES,
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function seal(key: CryptoKey, plain: Uint8Array): Promise<WrappedBytes> {
|
||||
const iv = randomBytes(12);
|
||||
const data = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buffer(iv) }, key, buffer(plain)));
|
||||
return { iv, data };
|
||||
}
|
||||
|
||||
/** Throws when the key is the wrong one: that's how a wrong PIN shows. */
|
||||
export async function open(key: CryptoKey, wrapped: WrappedBytes): Promise<Uint8Array> {
|
||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buffer(wrapped.iv) }, key, buffer(wrapped.data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* What shows the desktop app's main process that someone really unlocked: only the lock
|
||||
* key's bytes give it, and they exist only just after a real unlock. Main keeps a hash.
|
||||
*/
|
||||
export async function mainProof(raw: Uint8Array): Promise<Uint8Array> {
|
||||
const base = await crypto.subtle.importKey('raw', buffer(raw), 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PROOF_INFO },
|
||||
base,
|
||||
256,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
/** The lock key for use. It can wrap and unwrap, and can't be read back out. */
|
||||
export function importLockKey(raw: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', buffer(raw), AES, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** The layer around each account's database key. The IV goes in front of the bytes. */
|
||||
export function lockLayer(key: CryptoKey): WrapLayer {
|
||||
return {
|
||||
name: LOCK_LAYER,
|
||||
async wrap(data) {
|
||||
const iv = randomBytes(12);
|
||||
const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buffer(iv) }, key, buffer(data)));
|
||||
const out = new Uint8Array(iv.length + sealed.length);
|
||||
out.set(iv);
|
||||
out.set(sealed, iv.length);
|
||||
return out;
|
||||
},
|
||||
async unwrap(data) {
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buffer(data.slice(0, 12)) }, key, buffer(data.slice(12)));
|
||||
return new Uint8Array(plain);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Time without use, counted across all windows and tabs: every one of them writes
|
||||
* to the same localStorage key and reads from it. Clocks jump over sleep, which is
|
||||
* what's wanted: a laptop opened after the timeout locks at once.
|
||||
*/
|
||||
|
||||
import { APP_LOCK_ACTIVITY_KEY } from './state';
|
||||
|
||||
const WRITE_EVERY_MS = 5_000;
|
||||
const CHECK_EVERY_MS = 10_000;
|
||||
const EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'scroll'] as const;
|
||||
|
||||
export function lastActivity(): number {
|
||||
try {
|
||||
const at = Number(localStorage.getItem(APP_LOCK_ACTIVITY_KEY));
|
||||
return Number.isFinite(at) ? at : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function noteActivity(now = Date.now()): void {
|
||||
try {
|
||||
localStorage.setItem(APP_LOCK_ACTIVITY_KEY, String(now));
|
||||
} catch {
|
||||
// This tab's own timer then runs from when it started.
|
||||
}
|
||||
}
|
||||
|
||||
export interface IdleWatchOptions {
|
||||
timeoutMs: () => number;
|
||||
onIdle: () => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onIdle` once the time has passed. A touch after it has passed locks rather
|
||||
* than counts: coming back to the machine must not undo the timeout. Returns the stop.
|
||||
*/
|
||||
export function watchIdle(options: IdleWatchOptions): () => void {
|
||||
const now = options.now ?? Date.now;
|
||||
let last = now();
|
||||
noteActivity(last);
|
||||
let written = last;
|
||||
let stopped = false;
|
||||
|
||||
const expired = (at: number) => at - Math.max(last, lastActivity()) >= options.timeoutMs();
|
||||
const fire = () => {
|
||||
if (stopped) return;
|
||||
stop();
|
||||
options.onIdle();
|
||||
};
|
||||
const check = () => {
|
||||
if (expired(now())) fire();
|
||||
};
|
||||
const onUse = () => {
|
||||
const at = now();
|
||||
if (expired(at)) {
|
||||
fire();
|
||||
return;
|
||||
}
|
||||
last = at;
|
||||
if (at - written >= WRITE_EVERY_MS) {
|
||||
written = at;
|
||||
noteActivity(at);
|
||||
}
|
||||
};
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check();
|
||||
};
|
||||
|
||||
for (const event of EVENTS) window.addEventListener(event, onUse, { capture: true, passive: true });
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('focus', check);
|
||||
const timer = setInterval(check, CHECK_EVERY_MS);
|
||||
|
||||
function stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
for (const event of EVENTS) window.removeEventListener(event, onUse, { capture: true });
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('focus', check);
|
||||
}
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* WebAuthn with PRF, to wrap the lock key. There is no server in this: nothing checks
|
||||
* the assertion, and the challenge is only there because the API asks for one. What
|
||||
* counts is the 32 bytes the authenticator derives, without which the wrap can't open.
|
||||
*
|
||||
* A browser can't say ahead of time whether a new passkey has PRF, so it is tried right
|
||||
* after creating it. The PIN stays either way.
|
||||
*/
|
||||
|
||||
import { randomBytes } from './crypto';
|
||||
|
||||
export class PasskeyNoPrfError extends Error {
|
||||
constructor() {
|
||||
super('This passkey has no PRF');
|
||||
this.name = 'PasskeyNoPrfError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PrfResults {
|
||||
enabled?: boolean;
|
||||
results?: { first?: ArrayBuffer };
|
||||
}
|
||||
|
||||
function buffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export function passkeysPossible(): boolean {
|
||||
return typeof window !== 'undefined' && window.isSecureContext && typeof window.PublicKeyCredential === 'function' && !!navigator.credentials;
|
||||
}
|
||||
|
||||
function prfOf(credential: PublicKeyCredential): PrfResults | undefined {
|
||||
return (credential.getClientExtensionResults() as { prf?: PrfResults }).prf;
|
||||
}
|
||||
|
||||
/** The authenticator's answer for `salt`. Null when this one has no PRF. */
|
||||
export async function passkeyPrf(credentialId: Uint8Array, salt: Uint8Array): Promise<ArrayBuffer | null> {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: buffer(randomBytes(32)),
|
||||
rpId: location.hostname,
|
||||
allowCredentials: [{ type: 'public-key', id: buffer(credentialId) }],
|
||||
userVerification: 'required',
|
||||
timeout: 120_000,
|
||||
extensions: { prf: { eval: { first: buffer(salt) } } } as AuthenticationExtensionsClientInputs,
|
||||
},
|
||||
}) as PublicKeyCredential | null;
|
||||
if (!credential) return null;
|
||||
return prfOf(credential)?.results?.first ?? null;
|
||||
}
|
||||
|
||||
export interface NewPasskey {
|
||||
credentialId: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
prfOutput: ArrayBuffer;
|
||||
}
|
||||
|
||||
/** Throws PasskeyNoPrfError when the new passkey turns out to have no PRF. */
|
||||
export async function createPasskey(appName: string): Promise<NewPasskey> {
|
||||
const salt = randomBytes(32);
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
rp: { name: appName, id: location.hostname },
|
||||
user: { id: buffer(randomBytes(16)), name: appName, displayName: appName },
|
||||
challenge: buffer(randomBytes(32)),
|
||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
|
||||
authenticatorSelection: { userVerification: 'required', residentKey: 'discouraged' },
|
||||
timeout: 120_000,
|
||||
// Some authenticators only say they have PRF when asked with an input.
|
||||
extensions: { prf: { eval: { first: buffer(salt) } } } as AuthenticationExtensionsClientInputs,
|
||||
},
|
||||
}) as PublicKeyCredential | null;
|
||||
if (!credential) throw new PasskeyNoPrfError();
|
||||
const credentialId = new Uint8Array(credential.rawId);
|
||||
const made = prfOf(credential);
|
||||
if (made?.enabled === false) throw new PasskeyNoPrfError();
|
||||
// Windows Hello and others only give the bytes on `get`, so ask once to be sure.
|
||||
const prfOutput = made?.results?.first ?? await passkeyPrf(credentialId, salt);
|
||||
if (!prfOutput) throw new PasskeyNoPrfError();
|
||||
return { credentialId, salt, prfOutput };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The desktop app's OS policies for the lock, read through the bridge. In a browser
|
||||
* there's no bridge and nothing is required. The bridge's `policy` member comes from
|
||||
* another package, so it is feature detected, as lib/offline/scope.ts does.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from './state';
|
||||
|
||||
interface PolicyEntry<T> {
|
||||
value: T;
|
||||
locked: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface LockPolicies {
|
||||
AppLockRequired?: PolicyEntry<boolean>;
|
||||
AppLockTimeoutMinutes?: PolicyEntry<number | null>;
|
||||
}
|
||||
|
||||
interface PolicyBridge {
|
||||
get?: () => LockPolicies | null | undefined;
|
||||
onChange?: (listener: (snapshot: LockPolicies) => void) => () => void;
|
||||
}
|
||||
|
||||
function policyBridge(): PolicyBridge | undefined {
|
||||
return (getDesktopBridge() as { policy?: PolicyBridge } | null)?.policy;
|
||||
}
|
||||
|
||||
function read(): LockPolicies {
|
||||
try {
|
||||
return policyBridge()?.get?.() ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Only a forced value requires it: a recommended one can't choose a PIN for anyone. */
|
||||
export function appLockRequired(): boolean {
|
||||
const entry = read().AppLockRequired;
|
||||
return entry?.locked === true && entry.value === true;
|
||||
}
|
||||
|
||||
export interface EffectiveTimeout {
|
||||
minutes: number;
|
||||
/** Set by the organisation: the user's choice doesn't apply. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/** Forced, then the user's choice, then recommended, then the default. */
|
||||
export function effectiveTimeout(choice: number | undefined): EffectiveTimeout {
|
||||
const entry = read().AppLockTimeoutMinutes;
|
||||
const value = typeof entry?.value === 'number' && entry.value > 0 ? entry.value : null;
|
||||
if (entry?.locked && value !== null) return { minutes: value, locked: true };
|
||||
return { minutes: choice ?? value ?? DEFAULT_TIMEOUT_MINUTES, locked: false };
|
||||
}
|
||||
|
||||
export function onLockPolicyChange(listener: () => void): () => void {
|
||||
try {
|
||||
return policyBridge()?.onChange?.(() => listener()) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* What the app keeps in plain browser storage that names mail. A lock must not leave
|
||||
* it readable, so it goes before the lock screen comes up. Text that could not be saved
|
||||
* anywhere else is sealed first (see lib/app-lock/vault.ts).
|
||||
*/
|
||||
|
||||
import { openSealedLocal, sealDraft, sealLocal } from './vault';
|
||||
|
||||
/** Subjects and senders: a list snapshot for a fast start, and the Pro tabs' names. */
|
||||
const LOCAL_KEYS = ['email-snapshot', 'pro-tabs'];
|
||||
/** People's names, and what notices said. Nothing fetches these back, so they wait sealed for the unlock. */
|
||||
const SEALED_LOCAL_KEYS = ['contact-storage', 'notification-center'];
|
||||
const SESSION_KEYS = ['bulwark-offline-lost'];
|
||||
const SESSION_PREFIXES = ['bulwark-popout-compose:'];
|
||||
const RESCUED_KEY = 'bulwark-rescued-drafts';
|
||||
|
||||
function removeSessionKeys(): void {
|
||||
for (const key of SESSION_KEYS) sessionStorage.removeItem(key);
|
||||
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key && SESSION_PREFIXES.some((prefix) => key.startsWith(prefix))) sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrubBrowserStorage(): Promise<void> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RESCUED_KEY);
|
||||
const rescued: unknown = raw ? JSON.parse(raw) : [];
|
||||
if (Array.isArray(rescued)) for (const draft of rescued) await sealDraft(draft);
|
||||
sessionStorage.removeItem(RESCUED_KEY);
|
||||
} catch {
|
||||
// Nothing readable to keep.
|
||||
}
|
||||
try {
|
||||
removeSessionKeys();
|
||||
} catch {
|
||||
// Storage blocked: nothing was kept there either.
|
||||
}
|
||||
for (const key of SEALED_LOCAL_KEYS) {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value === null) continue;
|
||||
await sealLocal(key, value).catch(() => false);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// As above.
|
||||
}
|
||||
}
|
||||
for (const key of LOCAL_KEYS) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// As above.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts back what a lock sealed. Only the tab with the lock key can. */
|
||||
export async function restoreSealedStorage(lockKey: CryptoKey): Promise<void> {
|
||||
try {
|
||||
for (const [key, value] of await openSealedLocal(lockKey)) localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// They stay sealed for the next unlock.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stores read storage once, at import: before anyone unlocked. They read it again now. */
|
||||
export async function reloadSealedStores(): Promise<void> {
|
||||
try {
|
||||
const { reloadNotificationCenter } = await import('@/stores/notification-center-store');
|
||||
reloadNotificationCenter();
|
||||
const { useContactStore } = await import('@/stores/contact-store');
|
||||
await useContactStore.persist?.rehydrate();
|
||||
} catch {
|
||||
// What's on disk is there at the next load.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The sign-out's side of the lock, and what a forgotten PIN leaves to do. Apart from
|
||||
* controller.ts because lib/sign-out-cleanup.ts is in every page's bundle and is imported
|
||||
* by what the controller imports.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { writeAppLockFile } from './state';
|
||||
import { deleteVault } from './vault';
|
||||
|
||||
/** The session cookies are HttpOnly, so only the server can delete them, and it may be out of reach. */
|
||||
export const COOKIES_TO_CLEAR_KEY = 'bulwark-cookies-to-clear';
|
||||
/** The next login has to be a real one. See `requireFreshLogin`. */
|
||||
export const FRESH_LOGIN_KEY = 'bulwark-fresh-login';
|
||||
|
||||
const COOKIE_CLEAR_MS = 3_000;
|
||||
|
||||
/** What shows main the tab really holds the key, in this tab's memory for as long as it does. */
|
||||
let heldProof: Uint8Array | null = null;
|
||||
|
||||
export function rememberMainProof(proof: Uint8Array | null): void {
|
||||
heldProof = proof;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nobody is left to lock for. The tab with the key shows main its proof. Any other tab has
|
||||
* none, and main asks the user natively: a word from a page can't take a lock away.
|
||||
*/
|
||||
export async function forgetAppLockAtSignOut(): Promise<void> {
|
||||
const gone = deleteVault();
|
||||
writeAppLockFile({ on: false, locked: false });
|
||||
const proof = heldProof;
|
||||
heldProof = null;
|
||||
void getDesktopBridge()?.appLock?.forget(proof ?? undefined).catch(() => false);
|
||||
await gone;
|
||||
}
|
||||
|
||||
function mark(key: string, on: boolean): void {
|
||||
try {
|
||||
if (on) localStorage.setItem(key, '1');
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage blocked: nothing was kept there either.
|
||||
}
|
||||
}
|
||||
|
||||
function marked(key: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Before anything else in a forgotten PIN: from here on a leftover cookie mustn't sign anyone in. */
|
||||
export function markCookiesToClear(): void {
|
||||
mark(COOKIES_TO_CLEAR_KEY, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries the DELETE when a mark says it's due. True when nothing is left to clear. While it's
|
||||
* false, a cookie may still be there, and nothing may adopt it (stores/auth-store.ts).
|
||||
*/
|
||||
export async function clearPendingCookies(): Promise<boolean> {
|
||||
if (!marked(COOKIES_TO_CLEAR_KEY)) return true;
|
||||
const asked = Promise.all(['/api/auth/session?all=true', '/api/auth/token?all=true'].map((path) => (
|
||||
apiFetch(path, { method: 'DELETE', keepalive: true, signal: AbortSignal.timeout(COOKIE_CLEAR_MS) })
|
||||
))).then((answers) => answers.every((answer) => answer.ok)).catch(() => false);
|
||||
// A server that hangs mustn't keep a start or a way out waiting.
|
||||
const cleared = await Promise.race([asked, new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COOKIE_CLEAR_MS))]);
|
||||
if (cleared) mark(COOKIES_TO_CLEAR_KEY, false);
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* A forgotten PIN leaves the identity provider's session open: ending it takes the server
|
||||
* and a page of the provider's, and it isn't only this app's to end. So anyone at this
|
||||
* device could walk back in through it with no PIN and no password. The next login asks
|
||||
* the provider for a real one (`prompt=login`, in the flows that go through it). A provider
|
||||
* may ignore that, and the settings text doesn't promise more than asking.
|
||||
*/
|
||||
export function requireFreshLogin(): void {
|
||||
mark(FRESH_LOGIN_KEY, true);
|
||||
}
|
||||
|
||||
export function freshLoginRequired(): boolean {
|
||||
return marked(FRESH_LOGIN_KEY);
|
||||
}
|
||||
|
||||
export function freshLoginDone(): void {
|
||||
mark(FRESH_LOGIN_KEY, false);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* App lock's plain facts, and how tabs tell each other. No secrets and no mail:
|
||||
* whether the lock is on, whether it is locked now, and the timeout. This stays
|
||||
* in localStorage because the first frame has to know, before anything async can
|
||||
* run. Someone who can edit it can't unlock anything: the keys need the PIN. Someone
|
||||
* who removes it gets nothing either: a start looks for the vault before it goes on
|
||||
* (lib/app-lock/controller.ts).
|
||||
*
|
||||
* Imports nothing, so anything may import it: lib/popout/channel.ts, the store's
|
||||
* flush and the auth store all ask here.
|
||||
*/
|
||||
|
||||
export const APP_LOCK_KEY = 'bulwark-app-lock';
|
||||
/** When someone last used a window or tab. Shared by all of them. */
|
||||
export const APP_LOCK_ACTIVITY_KEY = 'bulwark-app-lock-activity';
|
||||
export const APP_LOCK_CHANNEL = 'bulwark:app-lock';
|
||||
/** Web Lock held by the tab that has the lock key, for as long as it lives. */
|
||||
export const APP_LOCK_HOLDER = 'bulwark-app-lock-holder';
|
||||
|
||||
export const DEFAULT_TIMEOUT_MINUTES = 10;
|
||||
export const TIMEOUT_CHOICES = [1, 5, 10, 15, 30, 60, 240] as const;
|
||||
|
||||
export interface AppLockFile {
|
||||
on: boolean;
|
||||
locked: boolean;
|
||||
/** The user's choice. Absent until they make one. */
|
||||
timeoutMinutes?: number;
|
||||
}
|
||||
|
||||
const OFF: AppLockFile = { on: false, locked: false };
|
||||
|
||||
export function readAppLockFile(): AppLockFile {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(APP_LOCK_KEY) ?? 'null') as Partial<AppLockFile> | null;
|
||||
if (!parsed || typeof parsed !== 'object' || parsed.on !== true) return OFF;
|
||||
return {
|
||||
on: true,
|
||||
// Anything but a clear "no" counts as locked.
|
||||
locked: parsed.locked !== false,
|
||||
...(typeof parsed.timeoutMinutes === 'number' ? { timeoutMinutes: parsed.timeoutMinutes } : {}),
|
||||
};
|
||||
} catch {
|
||||
return OFF;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAppLockFile(next: AppLockFile): void {
|
||||
try {
|
||||
if (next.on) localStorage.setItem(APP_LOCK_KEY, JSON.stringify(next));
|
||||
else localStorage.removeItem(APP_LOCK_KEY);
|
||||
} catch {
|
||||
// Storage blocked: this tab's memory below still says what it knows.
|
||||
}
|
||||
}
|
||||
|
||||
/** Set while this tab is on its way to locked, or shows the lock screen. */
|
||||
let lockedHere = false;
|
||||
|
||||
export function setLockedHere(locked: boolean): void {
|
||||
lockedHere = locked;
|
||||
}
|
||||
|
||||
/** Nothing with mail in it may show, go out or be read now. */
|
||||
export function isAppLockedNow(): boolean {
|
||||
if (lockedHere) return true;
|
||||
if (typeof window === 'undefined') return false;
|
||||
const file = readAppLockFile();
|
||||
return file.on && file.locked;
|
||||
}
|
||||
|
||||
export function isAppLockOn(): boolean {
|
||||
return typeof window !== 'undefined' && readAppLockFile().on;
|
||||
}
|
||||
|
||||
export type AppLockMessage =
|
||||
| { type: 'lock' }
|
||||
| { type: 'unlocked' }
|
||||
/** The lock was switched on in another tab. */
|
||||
| { type: 'enabled' }
|
||||
| { type: 'off' }
|
||||
/** Asks whether a main window is there: a pop-out sends people to it to unlock. */
|
||||
| { type: 'main-ping' }
|
||||
| { type: 'main-pong' }
|
||||
| { type: 'focus-main' };
|
||||
|
||||
export function postAppLockMessage(message: AppLockMessage): void {
|
||||
if (typeof BroadcastChannel === 'undefined') return;
|
||||
try {
|
||||
const channel = new BroadcastChannel(APP_LOCK_CHANNEL);
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
} catch {
|
||||
// The `storage` event on the file still reaches the other tabs.
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs in the head script, before the first paint. Self-contained: it is stringified. */
|
||||
export function appLockBootScript(): string {
|
||||
return `try {
|
||||
var appLock = JSON.parse(localStorage.getItem(${JSON.stringify(APP_LOCK_KEY)}) || 'null');
|
||||
if (appLock && appLock.on === true) document.documentElement.setAttribute('data-app-locked', '');
|
||||
} catch (e) {}`;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* What app lock keeps on disk, in its own IndexedDB database: the lock key wrapped
|
||||
* under the PIN and the other ways in, the count of wrong PINs, and drafts sealed
|
||||
* on their way to the lock screen.
|
||||
*
|
||||
* The count is written before a PIN is checked, so reloading during the check or
|
||||
* after a wrong one gains nothing. This slows guessing through the app. Someone
|
||||
* with the disk can skip it, and then only the PIN's length and PBKDF2 stand in the
|
||||
* way, which is why a PIN has a minimum length, letters are allowed and the obvious
|
||||
* ones are refused.
|
||||
*
|
||||
* In the desktop app the PIN's wrap goes through main's safeStorage as well, so a copy of
|
||||
* the disk can't be guessed at without this user's OS login. A browser has nothing like
|
||||
* that, and the settings page says so. A shell whose storage only obfuscates (Linux with
|
||||
* no secret service) says no, and the wrap stays as it is.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { sealFor, createSessionOpener, type SealedAnswer } from '@/lib/popout/sealed';
|
||||
import { keyFromPin, keyFromPrf, ObviousPinError, open, PBKDF2_ITERATIONS, pinIsObvious, randomBytes, seal, type WrappedBytes } from './crypto';
|
||||
|
||||
export const VAULT_DB = 'bulwark-app-lock';
|
||||
const DB_VERSION = 1;
|
||||
const RECORDS = 'records';
|
||||
const SEALED = 'sealed';
|
||||
const VAULT_ID = 'vault';
|
||||
const ATTEMPTS_ID = 'attempts';
|
||||
|
||||
export interface PinWrap extends WrappedBytes {
|
||||
salt: Uint8Array;
|
||||
iterations: number;
|
||||
/** `data` is wrapped once more by the desktop app's main process. */
|
||||
shell?: true;
|
||||
}
|
||||
|
||||
export interface PrfWrap extends WrappedBytes {
|
||||
credentialId: Uint8Array;
|
||||
/** What the authenticator's PRF is asked about. */
|
||||
salt: Uint8Array;
|
||||
}
|
||||
|
||||
export interface VaultRecord {
|
||||
id: typeof VAULT_ID;
|
||||
pin: PinWrap;
|
||||
prf?: PrfWrap;
|
||||
/** For sealing drafts in tabs with no key: anyone may seal, only the lock key opens. */
|
||||
sealing: { publicKey: JsonWebKey; privateKey: WrappedBytes };
|
||||
}
|
||||
|
||||
export interface AttemptsRecord {
|
||||
id: typeof ATTEMPTS_ID;
|
||||
failures: number;
|
||||
blockedUntil: number;
|
||||
}
|
||||
|
||||
function promisify<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function done(tx: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onabort = () => reject(tx.error);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
const request = indexedDB.open(VAULT_DB, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(SEALED)) db.createObjectStore(SEALED, { keyPath: 'id', autoIncrement: true });
|
||||
};
|
||||
return promisify(request);
|
||||
}
|
||||
|
||||
async function withDb<T>(run: (db: IDBDatabase) => Promise<T>): Promise<T> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await run(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readRecord<T>(id: string): Promise<T | null> {
|
||||
return withDb(async (db) => (await promisify(db.transaction(RECORDS, 'readonly').objectStore(RECORDS).get(id)) as T | undefined) ?? null);
|
||||
}
|
||||
|
||||
async function writeRecord(record: VaultRecord | AttemptsRecord): Promise<void> {
|
||||
await withDb(async (db) => {
|
||||
const tx = db.transaction(RECORDS, 'readwrite');
|
||||
tx.objectStore(RECORDS).put(record);
|
||||
await done(tx);
|
||||
});
|
||||
}
|
||||
|
||||
export function readVault(): Promise<VaultRecord | null> {
|
||||
return readRecord<VaultRecord>(VAULT_ID);
|
||||
}
|
||||
|
||||
/** Without creating the database by looking. A vault means the lock is on, whatever else says. */
|
||||
export async function vaultExists(): Promise<boolean> {
|
||||
if (typeof indexedDB === 'undefined') return false;
|
||||
try {
|
||||
if (typeof indexedDB.databases === 'function' && !(await indexedDB.databases()).some((db) => db.name === VAULT_DB)) return false;
|
||||
return (await readVault()) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A vault holds the lock key the store's keys depend on: a new one would lock them out for good. */
|
||||
export class VaultExistsError extends Error {
|
||||
constructor() {
|
||||
super('There is a vault already');
|
||||
this.name = 'VaultExistsError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Main didn't open the PIN's wrap: another user, or a copied profile. Says nothing about the PIN. */
|
||||
export class VaultShellError extends Error {
|
||||
constructor() {
|
||||
super('The desktop app could not open the PIN record');
|
||||
this.name = 'VaultShellError';
|
||||
}
|
||||
}
|
||||
|
||||
async function throughShell(data: Uint8Array): Promise<{ data: Uint8Array; shell?: true }> {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!member?.wrap) return { data };
|
||||
const status = await member.status().catch(() => null);
|
||||
if (!status?.wrap) return { data };
|
||||
const wrapped = await member.wrap(data).catch(() => null);
|
||||
return wrapped ? { data: wrapped, shell: true } : { data };
|
||||
}
|
||||
|
||||
async function outOfShell(pin: PinWrap): Promise<Uint8Array> {
|
||||
if (!pin.shell) return pin.data;
|
||||
const data = await getDesktopBridge()?.appLock?.unwrap?.(pin.data).catch(() => null);
|
||||
if (!data) throw new VaultShellError();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function pinWrapOf(pin: string, raw: Uint8Array): Promise<PinWrap> {
|
||||
const salt = randomBytes(32);
|
||||
const key = await keyFromPin(pin, salt, PBKDF2_ITERATIONS);
|
||||
const sealed = await seal(key, raw);
|
||||
return { salt, iterations: PBKDF2_ITERATIONS, iv: sealed.iv, ...(await throughShell(sealed.data)) };
|
||||
}
|
||||
|
||||
/** A new lock key under this PIN. The caller holds the bytes only long enough to use them. */
|
||||
export async function createVault(pin: string): Promise<Uint8Array> {
|
||||
if (pinIsObvious(pin)) throw new ObviousPinError();
|
||||
if (await vaultExists()) throw new VaultExistsError();
|
||||
const raw = randomBytes(32);
|
||||
const lockKey = await crypto.subtle.importKey('raw', raw.slice().buffer as ArrayBuffer, 'AES-GCM', false, ['encrypt']);
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']);
|
||||
const privateJwk = new TextEncoder().encode(JSON.stringify(await crypto.subtle.exportKey('jwk', pair.privateKey)));
|
||||
const record: VaultRecord = {
|
||||
id: VAULT_ID,
|
||||
pin: await pinWrapOf(pin, raw),
|
||||
sealing: {
|
||||
publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey),
|
||||
privateKey: await seal(lockKey, privateJwk),
|
||||
},
|
||||
};
|
||||
await writeRecord(record);
|
||||
await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 });
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Wrong PINs that cost nothing. A typo or two shouldn't make anyone wait. */
|
||||
export const FREE_FAILURES = 4;
|
||||
const FIRST_WAIT_MS = 30_000;
|
||||
/** A long wait, and no wipe: forgetting a PIN must not be the way mail gets deleted. */
|
||||
export const LONGEST_WAIT_MS = 60 * 60_000;
|
||||
|
||||
/** How long after this many wrong tries the next is allowed. */
|
||||
export function waitAfter(failures: number): number {
|
||||
if (failures <= FREE_FAILURES) return 0;
|
||||
return Math.min(FIRST_WAIT_MS * 2 ** (failures - FREE_FAILURES - 1), LONGEST_WAIT_MS);
|
||||
}
|
||||
|
||||
export class PinBlockedError extends Error {
|
||||
constructor(readonly until: number) {
|
||||
super('Too many wrong PINs');
|
||||
this.name = 'PinBlockedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WrongPinError extends Error {
|
||||
constructor() {
|
||||
super('Wrong PIN');
|
||||
this.name = 'WrongPinError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function pinBlockedUntil(now = Date.now()): Promise<number> {
|
||||
const attempts = await readRecord<AttemptsRecord>(ATTEMPTS_ID);
|
||||
return attempts && attempts.blockedUntil > now ? attempts.blockedUntil : 0;
|
||||
}
|
||||
|
||||
/** The lock key's bytes for the right PIN. Counts first, checks after. */
|
||||
export async function openVaultWithPin(pin: string, now = Date.now()): Promise<Uint8Array> {
|
||||
const vault = await readVault();
|
||||
if (!vault) throw new WrongPinError();
|
||||
const attempts = (await readRecord<AttemptsRecord>(ATTEMPTS_ID)) ?? { id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 };
|
||||
if (attempts.blockedUntil > now) throw new PinBlockedError(attempts.blockedUntil);
|
||||
// Before the count: main saying no costs nobody a try.
|
||||
const data = await outOfShell(vault.pin);
|
||||
const failures = attempts.failures + 1;
|
||||
await writeRecord({ id: ATTEMPTS_ID, failures, blockedUntil: now + waitAfter(failures) });
|
||||
try {
|
||||
const key = await keyFromPin(pin, vault.pin.salt, vault.pin.iterations);
|
||||
const raw = await open(key, { iv: vault.pin.iv, data });
|
||||
await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 });
|
||||
return raw;
|
||||
} catch {
|
||||
throw new WrongPinError();
|
||||
}
|
||||
}
|
||||
|
||||
/** Only ever the PIN's own wrap: the lock key and everything under it stay. */
|
||||
export async function changeVaultPin(currentPin: string, newPin: string): Promise<void> {
|
||||
if (pinIsObvious(newPin)) throw new ObviousPinError();
|
||||
const raw = await openVaultWithPin(currentPin);
|
||||
try {
|
||||
const vault = await readVault();
|
||||
if (!vault) throw new WrongPinError();
|
||||
await writeRecord({ ...vault, pin: await pinWrapOf(newPin, raw) });
|
||||
} finally {
|
||||
raw.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** The passkey's way in. `prfOutput` is what the authenticator gave for `salt`. */
|
||||
export async function addVaultPrf(raw: Uint8Array, credentialId: Uint8Array, salt: Uint8Array, prfOutput: ArrayBuffer | Uint8Array): Promise<void> {
|
||||
const vault = await readVault();
|
||||
if (!vault) throw new Error('The app lock is not set up');
|
||||
await writeRecord({ ...vault, prf: { credentialId, salt, ...(await seal(await keyFromPrf(prfOutput), raw)) } });
|
||||
}
|
||||
|
||||
export async function removeVaultPrf(): Promise<void> {
|
||||
const vault = await readVault();
|
||||
if (!vault?.prf) return;
|
||||
const { prf: _gone, ...rest } = vault;
|
||||
await writeRecord(rest);
|
||||
}
|
||||
|
||||
export async function openVaultWithPrf(prfOutput: ArrayBuffer | Uint8Array): Promise<Uint8Array> {
|
||||
const vault = await readVault();
|
||||
if (!vault?.prf) throw new WrongPinError();
|
||||
return open(await keyFromPrf(prfOutput), vault.prf);
|
||||
}
|
||||
|
||||
/** Deleted at sign-out and when the lock goes off. The store's keys can't depend on it then. */
|
||||
export async function deleteVault(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
const request = indexedDB.deleteDatabase(VAULT_DB);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
request.onblocked = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Drafts a locking window couldn't save ----
|
||||
|
||||
interface SealedRow {
|
||||
id?: number;
|
||||
sealed: SealedAnswer;
|
||||
/** The localStorage key this came out of. No key: a draft. */
|
||||
local?: string;
|
||||
}
|
||||
|
||||
function sealedRows(): Promise<SealedRow[]> {
|
||||
return withDb((db) => promisify(db.transaction(SEALED, 'readonly').objectStore(SEALED).getAll())) as Promise<SealedRow[]>;
|
||||
}
|
||||
|
||||
async function deleteSealedRows(rows: SealedRow[]): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
await withDb(async (db) => {
|
||||
const tx = db.transaction(SEALED, 'readwrite');
|
||||
for (const row of rows) tx.objectStore(SEALED).delete(row.id!);
|
||||
await done(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function openerFor(lockKey: CryptoKey, vault: VaultRecord) {
|
||||
const jwk = JSON.parse(new TextDecoder().decode(await open(lockKey, vault.sealing.privateKey))) as JsonWebKey;
|
||||
return createSessionOpener(jwk);
|
||||
}
|
||||
|
||||
/** Works in any tab, with no key: the public half is enough to seal. */
|
||||
export async function sealDraft(state: unknown): Promise<boolean> {
|
||||
const vault = await readVault();
|
||||
if (!vault) return false;
|
||||
const sealed = await sealFor(vault.sealing.publicKey, state);
|
||||
await withDb(async (db) => {
|
||||
const tx = db.transaction(SEALED, 'readwrite');
|
||||
tx.objectStore(SEALED).add({ sealed } satisfies SealedRow);
|
||||
await done(tx);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only with the lock key's bytes. Takes the drafts off the list. */
|
||||
export async function openSealedDrafts(lockKey: CryptoKey): Promise<unknown[]> {
|
||||
const vault = await readVault();
|
||||
if (!vault) return [];
|
||||
const rows = (await sealedRows()).filter((row) => !row.local);
|
||||
if (rows.length === 0) return [];
|
||||
const opener = await openerFor(lockKey, vault);
|
||||
const out: unknown[] = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
out.push(await opener.open(row.sealed));
|
||||
} catch {
|
||||
// Sealed under an older key: nothing can read it, and it must not block the rest.
|
||||
}
|
||||
}
|
||||
await deleteSealedRows(rows);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Stores that name people, taken out of plain storage while locked ----
|
||||
|
||||
/** Only the newest copy of a key stays. False with no vault to seal for. */
|
||||
export async function sealLocal(key: string, value: string): Promise<boolean> {
|
||||
const vault = await readVault();
|
||||
if (!vault) return false;
|
||||
const sealed = await sealFor(vault.sealing.publicKey, value);
|
||||
const older = (await sealedRows()).filter((row) => row.local === key);
|
||||
await deleteSealedRows(older);
|
||||
await withDb(async (db) => {
|
||||
const tx = db.transaction(SEALED, 'readwrite');
|
||||
tx.objectStore(SEALED).add({ sealed, local: key } satisfies SealedRow);
|
||||
await done(tx);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** With the lock key. What's opened comes off the list: the caller puts it back in storage. */
|
||||
export async function openSealedLocal(lockKey: CryptoKey): Promise<Array<[string, string]>> {
|
||||
const vault = await readVault();
|
||||
if (!vault) return [];
|
||||
const rows = (await sealedRows()).filter((row) => row.local);
|
||||
if (rows.length === 0) return [];
|
||||
const opener = await openerFor(lockKey, vault);
|
||||
const out: Array<[string, string]> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
out.push([row.local!, await opener.open(row.sealed) as string]);
|
||||
} catch {
|
||||
// As with a draft above.
|
||||
}
|
||||
}
|
||||
await deleteSealedRows(rows);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function countSealed(): Promise<number> {
|
||||
if (!(await vaultExists())) return 0;
|
||||
return (await sealedRows()).length;
|
||||
}
|
||||
|
||||
/** A wipe: sealed text can't be told apart by account, and a device told to remove it keeps none. */
|
||||
export async function clearSealed(): Promise<void> {
|
||||
if (!(await vaultExists())) return;
|
||||
await withDb(async (db) => {
|
||||
const tx = db.transaction(SEALED, 'readwrite');
|
||||
tx.objectStore(SEALED).clear();
|
||||
await done(tx);
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
* waits at most `timeoutMs` for all of them together.
|
||||
*/
|
||||
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import type { OpenDraft } from '@/lib/desktop/window-restore';
|
||||
|
||||
export type ComposeFlush = () => Promise<void>;
|
||||
|
||||
@@ -119,8 +121,33 @@ export async function flushComposers(timeoutMs: number): Promise<FlushResult> {
|
||||
return { total: pending.length, settled, failed, timedOut };
|
||||
}
|
||||
|
||||
/** What an open composer holds, for the app lock: where its text is, and the text if it's nowhere else. */
|
||||
export interface ComposeSnapshot {
|
||||
/** In its own window, whose address has to say what comes back after the lock. */
|
||||
ownWindow: boolean;
|
||||
/** The composer's state, when it has anything worth keeping. */
|
||||
state(): ComposerDraftData | null;
|
||||
/** The saved draft it's on. */
|
||||
draft(): OpenDraft | null;
|
||||
}
|
||||
|
||||
const snapshots = new Map<number, ComposeSnapshot>();
|
||||
|
||||
export function registerComposeSnapshot(snapshot: ComposeSnapshot): () => void {
|
||||
const id = nextId++;
|
||||
snapshots.set(id, snapshot);
|
||||
return () => {
|
||||
snapshots.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
export function composeSnapshots(): ComposeSnapshot[] {
|
||||
return [...snapshots.values()];
|
||||
}
|
||||
|
||||
/** Test helper: forget every registration. */
|
||||
export function resetComposeFlushRegistry(): void {
|
||||
flushes.clear();
|
||||
waiters.clear();
|
||||
snapshots.clear();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Whether the app is locked, for everything that would show mail outside the app.
|
||||
*
|
||||
* App lock isn't built yet. When it is, its code calls setAppLockCheck once with a
|
||||
* function that answers from its own state, and calls bridge.os.setLocked on every
|
||||
* change: main then clears the tray, the jump list and delivered toasts, and strips
|
||||
* anything that still comes through. Nothing else needs to change. Until then it
|
||||
* answers false.
|
||||
* lib/app-lock/controller.ts calls setAppLockCheck once, at the start of each page, with
|
||||
* a function that answers from its own state, and calls bridge.os.setLocked(true) when it
|
||||
* locks. components/desktop/desktop-os-host.tsx says it's open again when the app starts
|
||||
* behind the lock. Main then clears the tray, the jump list and delivered toasts, and strips
|
||||
* anything that still comes through. Main keeps its own answer too (the shell's
|
||||
* app-lock-gate.ts), which stands over what any page says.
|
||||
*/
|
||||
|
||||
let check: (() => boolean) | null = null;
|
||||
|
||||
@@ -821,8 +821,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
|
||||
AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' },
|
||||
AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
|
||||
UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
|
||||
AppLockRequired: { pendingConsumer: 'step 7, app lock' },
|
||||
AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' },
|
||||
AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
|
||||
AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
|
||||
TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
|
||||
CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
|
||||
QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
|
||||
|
||||
@@ -292,6 +292,39 @@ export interface DesktopPolicyBridge {
|
||||
onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void;
|
||||
}
|
||||
|
||||
export type DesktopOsUnlockKind = 'hello' | 'touchid';
|
||||
|
||||
export type DesktopOsUnlockAnswer = { ok: true; key: Uint8Array } | { ok: false; reason: 'unavailable' | 'denied' | 'not-locked' | 'failed' };
|
||||
|
||||
/**
|
||||
* App lock in the desktop app. Main holds whether it's locked, and nothing a page says can
|
||||
* lift that: `prove` needs what only a real unlock gives, Windows Hello and Touch ID run in
|
||||
* main, and `forget` asks natively without the proof. A same-origin plugin frame can reach
|
||||
* this through `parent`, so that has to hold on a call alone. What it mustn't reach at all,
|
||||
* `lock` and `osUnlock`, the page takes once with `claim` before any frame exists.
|
||||
*/
|
||||
export interface DesktopAppLockBridge {
|
||||
status: () => Promise<{ locked: boolean; enrolled: boolean; os: DesktopOsUnlockKind | null; osEnrolled: boolean; wrap?: boolean }>;
|
||||
/** Once per document: the calls only the page itself may make. Null for whoever comes second. */
|
||||
claim: () => { lock: () => void; osUnlock: () => Promise<DesktopOsUnlockAnswer> } | null;
|
||||
/** Sets up the proof. False when one exists. */
|
||||
enroll: (proof: Uint8Array) => Promise<boolean>;
|
||||
/** Lifts the lock when the proof is the right one. */
|
||||
prove: (proof: Uint8Array) => Promise<boolean>;
|
||||
/** The lock goes off for good. */
|
||||
disable: (proof: Uint8Array) => Promise<boolean>;
|
||||
/** The PIN is lost, or everyone signed out. With no proof, main asks the user natively. */
|
||||
forget: (proof?: Uint8Array) => Promise<boolean>;
|
||||
/** Through safeStorage, for this origin only. Null where the OS has no real key store. */
|
||||
wrap: (data: Uint8Array) => Promise<Uint8Array | null>;
|
||||
unwrap: (data: Uint8Array) => Promise<Uint8Array | null>;
|
||||
/** Asks Windows Hello or Touch ID, then keeps the key in the OS's encrypted storage. */
|
||||
osEnroll: (proof: Uint8Array, lockKey: Uint8Array) => Promise<boolean>;
|
||||
osRemove: () => Promise<void>;
|
||||
/** The screen locked, or "Lock now" in the menu or tray. Returns the unsubscribe. */
|
||||
onLockRequest: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopOsSettings {
|
||||
/** Closing the main window leaves the app running in the tray. */
|
||||
closeToTray: boolean;
|
||||
@@ -470,6 +503,7 @@ export interface BulwarkDesktopBridge {
|
||||
auth?: DesktopAuthBridge;
|
||||
plugins?: DesktopPluginsBridge;
|
||||
keystore?: DesktopKeystoreBridge;
|
||||
appLock?: DesktopAppLockBridge;
|
||||
privacy?: DesktopPrivacyBridge;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,89 @@ function expectUntouched(): void {
|
||||
expect(wipeState.readPendingWipes()).toEqual({});
|
||||
}
|
||||
|
||||
describe('with the app lock', () => {
|
||||
const vaultDb = 'bulwark-app-lock';
|
||||
|
||||
async function vaultOf(): Promise<typeof import('@/lib/app-lock/vault')> {
|
||||
return import('@/lib/app-lock/vault');
|
||||
}
|
||||
|
||||
it('a wipe of one account leaves the others their way in', async () => {
|
||||
const vault = await vaultOf();
|
||||
(await vault.createVault('correct-pin')).fill(0);
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
respond = () => noWipe();
|
||||
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
|
||||
await wipe.wipeAccountFromDevice(BOB, 'admin');
|
||||
expect(await databases()).toContain(vaultDb);
|
||||
expect(await vault.readVault()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('what was sealed goes with it: a device told to remove an account keeps none of its text', async () => {
|
||||
const vault = await vaultOf();
|
||||
(await vault.createVault('correct-pin')).fill(0);
|
||||
await vault.sealDraft({ subject: 'Text of a wiped account' });
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
respond = () => noWipe();
|
||||
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
|
||||
await wipe.wipeAccountFromDevice(BOB, 'admin');
|
||||
expect(await vault.countSealed()).toBe(0);
|
||||
});
|
||||
|
||||
it('with no account left, the vault and the lock go too', async () => {
|
||||
const vault = await vaultOf();
|
||||
(await vault.createVault('correct-pin')).fill(0);
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
respond = () => noWipe();
|
||||
await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 });
|
||||
await wipe.wipeAccountFromDevice(ALICE, 'admin');
|
||||
await vi.waitFor(async () => expect(await databases()).not.toContain(vaultDb));
|
||||
expect(localStorage.getItem('bulwark-app-lock')).toBeNull();
|
||||
});
|
||||
|
||||
it('also when the last account isn\'t the active one, as at a lock screen where nothing has started', async () => {
|
||||
const vault = await vaultOf();
|
||||
(await vault.createVault('correct-pin')).fill(0);
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
|
||||
wipe.setWipeReloadForTests(() => {});
|
||||
useAccountStore.setState({ accounts: [entry(BOB, 1)], activeAccountId: null, defaultAccountId: null } as never);
|
||||
useAuthStore.setState({ activeAccountId: null });
|
||||
respond = () => noWipe();
|
||||
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
|
||||
await wipe.wipeAccountFromDevice(BOB, 'admin');
|
||||
wipe.setWipeReloadForTests(null);
|
||||
await vi.waitFor(async () => expect(await databases()).not.toContain(vaultDb));
|
||||
expect(localStorage.getItem('bulwark-app-lock')).toBeNull();
|
||||
});
|
||||
|
||||
it('a wipe of the whole device takes them as well', async () => {
|
||||
const vault = await vaultOf();
|
||||
(await vault.createVault('correct-pin')).fill(0);
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
|
||||
respond = () => noWipe();
|
||||
await wipe.wipeWholeDevice('user');
|
||||
await vi.waitFor(async () => expect(await databases()).not.toContain(vaultDb));
|
||||
expect(localStorage.getItem('bulwark-app-lock')).toBeNull();
|
||||
});
|
||||
|
||||
it('a device at the lock screen hears a wipe: the check-in runs with no key and no session', async () => {
|
||||
const vault = await vaultOf();
|
||||
let reloads = 0;
|
||||
wipe.setWipeReloadForTests(() => { reloads += 1; });
|
||||
(await vault.createVault('correct-pin')).fill(0);
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
|
||||
respond = (url, init) => (url.includes('/api/devices') && JSON.parse(String(init?.body ?? '{}')).id === BOB_ROW ? wipeNow() : noWipe());
|
||||
await wipe.checkInDue(true);
|
||||
await vi.waitFor(async () => expect(await keys.getDeviceRecord(BOB)).toBeNull());
|
||||
expect(await vault.readVault(), 'Alice still has to get in').not.toBeNull();
|
||||
await vi.waitFor(() => expect(reloads, 'nothing started for Alice stays in memory').toBe(1));
|
||||
wipe.setWipeReloadForTests(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wipe on the next connect', () => {
|
||||
beforeEach(() => {
|
||||
respond = (url) => (url.endsWith('/api/devices/wiped') ? json(200, { ok: true }) : wipeNow());
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail';
|
||||
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
||||
import { enforcePolicy, startOfflineMode, wipeEverything } from '../offline-mode';
|
||||
import { setLockedHere } from '@/lib/app-lock/state';
|
||||
import { flushQueue } from '../flush';
|
||||
import { holdFile, readQueue } from '../queue';
|
||||
import { forgetLegacyMove } from '../migrate-legacy';
|
||||
@@ -253,6 +254,45 @@ describe('the outbox', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the app locking during a flush', () => {
|
||||
it('stops before the next message, and the rest goes after the unlock', async () => {
|
||||
const sent: unknown[] = [];
|
||||
const client = fakeClient({
|
||||
sendEmail: vi.fn(async (...args: unknown[]) => {
|
||||
sent.push(args);
|
||||
// The timer fires while the first send is under way.
|
||||
if (sent.length === 1) setLockedHere(true);
|
||||
return { scheduled: false, emailId: `sent-${sent.length}`, emailSubmissionId: `sub-${sent.length}` };
|
||||
}),
|
||||
});
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
writeEnabled(['acc']);
|
||||
stop = startOfflineMode();
|
||||
setOnline(false);
|
||||
await sendArgs(client, { outbox: true });
|
||||
await sendArgs(client, { outbox: true });
|
||||
// Only the flush below: nothing starts one of its own when the network comes back.
|
||||
stop();
|
||||
stop = () => {};
|
||||
setOnline(true);
|
||||
expect((await readQueue('acc')).outbox).toHaveLength(2);
|
||||
(client.sendEmail as ReturnType<typeof vi.fn>).mockClear();
|
||||
try {
|
||||
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
||||
} finally {
|
||||
setLockedHere(false);
|
||||
}
|
||||
expect(sent).toHaveLength(1);
|
||||
const waiting = (await readQueue('acc')).outbox;
|
||||
expect(waiting).toHaveLength(1);
|
||||
expect(waiting[0].status).toBe('waiting');
|
||||
|
||||
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
||||
expect(sent).toHaveLength(2);
|
||||
expect((await readQueue('acc')).outbox).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a send whose response got lost', () => {
|
||||
async function uncertainSend(client: ReturnType<typeof fakeClient>) {
|
||||
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
||||
|
||||
@@ -47,6 +47,9 @@ import { SW_SCOPE } from '@/lib/app-update';
|
||||
import { devicesForAccount, devicesForNoOne } from '@/lib/device-policy';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out';
|
||||
import { isAppLockedNow } from '@/lib/app-lock/state';
|
||||
import { clearSealed, VAULT_DB } from '@/lib/app-lock/vault';
|
||||
import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts';
|
||||
import { isPopoutWindow } from '@/lib/popout/window-role';
|
||||
import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
|
||||
@@ -110,6 +113,8 @@ const APP_DATABASES = new Set([
|
||||
'bulwark-company-calendars',
|
||||
'bulwark-mail-import',
|
||||
'bulwark-offline',
|
||||
// The other accounts' way in: it goes when no account is left, see `forgetLockIfNobodyLeft`.
|
||||
VAULT_DB,
|
||||
]);
|
||||
/** Deleted by name too, for browsers without `indexedDB.databases()`. */
|
||||
const KNOWN_PLUGIN_DATABASES = ['smime-plugin-store', 'bulwark-openpgp', 'smime-capability-probe', 'openpgp-capability-probe'];
|
||||
@@ -261,6 +266,12 @@ async function endWebPushIfNobodyLeft(): Promise<void> {
|
||||
await subscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/** With nobody left after this there's nothing to lock for, and a next person shouldn't meet it. */
|
||||
async function forgetLockIfNobodyStays(leaving: string | null): Promise<void> {
|
||||
if (leaving !== null && useAccountStore.getState().accounts.some((entry) => entry.id !== leaving)) return;
|
||||
await forgetAppLockAtSignOut();
|
||||
}
|
||||
|
||||
/** What's the account's own: a deletion after a refused login goes no further than this. */
|
||||
async function scrubAccount(account: string): Promise<void> {
|
||||
await runWipeCleaners(account);
|
||||
@@ -284,6 +295,7 @@ async function scrubShared(): Promise<void> {
|
||||
}
|
||||
clearHandoffs();
|
||||
clearRescuedDrafts();
|
||||
await clearSealed().catch(() => {});
|
||||
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
|
||||
useNotificationCenterStore.getState().clear();
|
||||
await closeShownNotifications().catch(() => {});
|
||||
@@ -409,12 +421,29 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc
|
||||
await putRowsBack(records, account);
|
||||
|
||||
await flushConfirmations(deleted ? account : undefined);
|
||||
const wasLocked = isAppLockedNow();
|
||||
if (!ownOnly) {
|
||||
await forgetLockIfNobodyStays(account).catch(() => {});
|
||||
await signOutAccount(account);
|
||||
await endWebPushIfNobodyLeft().catch(() => {});
|
||||
}
|
||||
if (deleted) clearWipePending(account);
|
||||
tellOtherWindows(account, 'end');
|
||||
// Heard at the lock screen: signing out may have started the next account here, and with
|
||||
// nobody left a new page is what takes the lock screen away.
|
||||
if (wasLocked && !ownOnly) reloadPage();
|
||||
}
|
||||
|
||||
let reloadSeam: (() => void) | null = null;
|
||||
|
||||
function reloadPage(): void {
|
||||
if (reloadSeam) reloadSeam();
|
||||
else window.location.reload();
|
||||
}
|
||||
|
||||
/** Test helper: jsdom can't reload. */
|
||||
export function setWipeReloadForTests(run: (() => void) | null): void {
|
||||
reloadSeam = run;
|
||||
}
|
||||
|
||||
/** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */
|
||||
@@ -455,6 +484,7 @@ async function eraseDevice(reason: WipeReason): Promise<void> {
|
||||
for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account);
|
||||
await flushConfirmations(deleted ? WHOLE_DEVICE : undefined);
|
||||
signalSignedOut();
|
||||
await forgetLockIfNobodyStays(null).catch(() => {});
|
||||
await useAuthStore.getState().logoutAll({ endProviderSession: false });
|
||||
await endWebPushIfNobodyLeft().catch(() => {});
|
||||
if (deleted) clearWipePending(WHOLE_DEVICE);
|
||||
@@ -467,6 +497,14 @@ function queueDrop(row: Pick<DeviceRecord, 'id' | 'secret'>): void {
|
||||
queueWipeConfirmation({ id: row.id, secret: row.secret, reason: 'user', unsent: 0, drop: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A forgotten PIN is about to delete the key database with the rows in it: the instance hears
|
||||
* at the next start that they're over. No network here, and that's what the order there needs.
|
||||
*/
|
||||
export async function forgetDeviceRows(): Promise<void> {
|
||||
for (const row of await listDeviceRecords().catch(() => [] as DeviceRecord[])) queueDrop(row);
|
||||
}
|
||||
|
||||
async function dropRows(scope: string): Promise<void> {
|
||||
const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]);
|
||||
const rows = scope === 'all' ? records : records.filter((record) => record.account === scope);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* (a label now required, a send check now required) is held for the user.
|
||||
*/
|
||||
|
||||
import { isAppLockedNow } from '@/lib/app-lock/state';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
import { isNetworkFailure } from '@/lib/jmap/offline-hooks';
|
||||
import type { SendEmailResult } from '@/lib/jmap/types';
|
||||
@@ -363,6 +364,8 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps,
|
||||
continue;
|
||||
}
|
||||
// Another tab took it, or it was deleted meanwhile.
|
||||
// Locked: the rest goes after the unlock, when the app starts again.
|
||||
if (isAppLockedNow()) return;
|
||||
if (!(await claimForSending(account, entry.id))) continue;
|
||||
let result: SendEmailResult;
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* messages a sign-out deletes without the user's word are named (lost.ts).
|
||||
*/
|
||||
|
||||
import { appLockKeyLayers, onStoreKeyMissing } from '@/lib/app-lock/controller';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
|
||||
import { devicesForAccount, devicesForNoOne, signedInAccountIds } from '@/lib/device-policy';
|
||||
@@ -221,6 +222,8 @@ function noteStoreUnavailable(): void {
|
||||
}
|
||||
|
||||
const storeHooks: StorePageHooks = {
|
||||
keyLayers: appLockKeyLayers,
|
||||
onKeyLocked: onStoreKeyMissing,
|
||||
inputs(account) {
|
||||
const client = onlineClientFor(account);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { goInApp, onPageNavigation, resetPageNavigation } from '../page-navigation';
|
||||
|
||||
describe('moving the tab', () => {
|
||||
afterEach(() => {
|
||||
resetPageNavigation();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('goes through the router when there is one: a full load would ask for the app lock PIN', () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal('location', { ...window.location, assign });
|
||||
const pushed: string[] = [];
|
||||
onPageNavigation((href) => pushed.push(href));
|
||||
goInApp('/calendar', '/en/calendar');
|
||||
expect(pushed).toEqual(['/calendar']);
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the page only where nothing has a router', () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal('location', { ...window.location, assign });
|
||||
goInApp('/calendar', '/en/calendar');
|
||||
expect(assign).toHaveBeenCalledWith('/en/calendar');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
// to press Send; drafts.create saves a draft to the Drafts folder without
|
||||
// opening anything. Neither sends mail.
|
||||
|
||||
import { goInApp } from './page-navigation';
|
||||
import type { ComposeInput, DraftInput, InstalledPlugin } from '../plugin-types';
|
||||
import type { ParsedMailto } from '../protocol-handlers/mailto';
|
||||
import { requestInternalCompose } from '../protocol-handlers/mailto';
|
||||
@@ -96,7 +97,7 @@ export function doComposeOpen(plugin: InstalledPlugin, input: unknown): 'opened'
|
||||
// incoming mailto: request when it mounts, and go there.
|
||||
savePendingMailto(draft);
|
||||
notifyPendingMailto();
|
||||
window.location.assign(mailPageHref());
|
||||
goInApp('/', mailPageHref());
|
||||
return 'navigating';
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { Email } from '../jmap/types';
|
||||
import type { InstalledPlugin, SearchResultEmail } from '../plugin-types';
|
||||
import { appPath, buildMailPath } from '../deep-links';
|
||||
import { goInApp } from './page-navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useProTabStore } from '@/stores/pro-tab-store';
|
||||
import { readId, readObject, withinRate } from './host-api-input';
|
||||
@@ -140,7 +141,7 @@ export async function requestOpenEmail(plugin: InstalledPlugin, input: unknown):
|
||||
if (!withinRate('open-email', plugin.id, LINK_LOADS_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${LINK_LOADS_PER_MINUTE} times a minute from outside the mail page`);
|
||||
}
|
||||
window.location.assign(appPath(messagePath(email)));
|
||||
goInApp(messagePath(email), appPath(messagePath(email)));
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
|
||||
@@ -54,6 +54,16 @@ export function navigateInApp(href: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the tab through the router, which keeps the page, and with it the app lock's key.
|
||||
* A full load makes the user type the PIN again.
|
||||
*/
|
||||
export function goInApp(routerHref: string, fullHref: string): void {
|
||||
if (navigateInApp(routerHref)) return;
|
||||
// full-load: no router in this window, as in a pop-out or before the app has mounted.
|
||||
window.location.assign(fullHref);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetPageNavigation(): void {
|
||||
handler = null;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import { isAppLockedNow } from '@/lib/app-lock/state';
|
||||
import type { MessagePriority } from '@/lib/message-priority';
|
||||
import type { ComposePopoutPayload } from './compose-payload';
|
||||
import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed';
|
||||
@@ -154,7 +155,7 @@ export async function requestWindowSession(
|
||||
options: { accountId?: string; rejectedAuthHeader?: string } = {},
|
||||
timeoutMs: number = SESSION_ANSWER_MS,
|
||||
): Promise<SharedSessionAnswer | null> {
|
||||
if (!canSealSessions()) return null;
|
||||
if (!canSealSessions() || isAppLockedNow()) return null;
|
||||
const opener = await createSessionOpener();
|
||||
const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined;
|
||||
return new Promise((resolve) => {
|
||||
@@ -167,6 +168,11 @@ export async function requestWindowSession(
|
||||
};
|
||||
const channel = openPopoutChannel((message) => {
|
||||
if (message.type !== 'session-answer' || message.requestId !== requestId) return;
|
||||
// Locked while the answer was on its way: it is not opened.
|
||||
if (isAppLockedNow()) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
void opener.open<SharedSessionAnswer>(message.sealed).then(finish, () => finish(null));
|
||||
});
|
||||
if (!channel) {
|
||||
|
||||
@@ -46,10 +46,15 @@ async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise<Crypt
|
||||
return crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** The asking window's side: a key to send along, and what opens the answer. */
|
||||
export async function createSessionOpener(): Promise<SessionOpener> {
|
||||
const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
|
||||
const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
/**
|
||||
* The asking window's side: a key to send along, and what opens the answer. With
|
||||
* `stored`, a private key kept elsewhere (app lock's sealed drafts) opens instead.
|
||||
*/
|
||||
export async function createSessionOpener(stored?: JsonWebKey): Promise<SessionOpener> {
|
||||
const pair = stored
|
||||
? { privateKey: await crypto.subtle.importKey('jwk', stored, ECDH, false, ['deriveKey']), publicKey: null }
|
||||
: await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
|
||||
const publicKey = pair.publicKey ? await crypto.subtle.exportKey('jwk', pair.publicKey) : {};
|
||||
return {
|
||||
publicKey,
|
||||
async open<T>(sealed: SealedAnswer): Promise<T> {
|
||||
|
||||
@@ -71,6 +71,8 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
|
||||
{ label: 'settings.language_region.time_format.label' },
|
||||
{ label: 'settings.language_region.time_zone.label' },
|
||||
{ label: 'settings.language_region.first_day.label' },
|
||||
{ label: 'appLock.section_title' },
|
||||
{ label: 'appLock.enable_label' },
|
||||
{ label: 'protocol_handlers.title' },
|
||||
{ label: 'protocol_handlers.mailto_label' },
|
||||
{ label: 'protocol_handlers.webcal_label' },
|
||||
@@ -373,7 +375,7 @@ export const ARCHIVE_ACTION_SEARCH_PATHS: ReadonlySet<string> = new Set(['settin
|
||||
|
||||
export const tabKeywords: Record<Tab, string> = {
|
||||
account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group',
|
||||
general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding',
|
||||
general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding lock pin passkey windows hello touch id',
|
||||
notifications: 'sound alert push badge favicon tab icon unread count background relay device',
|
||||
appearance: 'theme dark light font size accent color animation density custom theme css skin toolbar sidebar account switcher icons rail layout reading pane',
|
||||
accessibility: 'a11y accessible vision blind low vision screen reader contrast motion animation reduce zoom text size font dyslexia spacing keyboard shortcuts focus outline cursor pointer targets colour blind color sound visual alert hearing deaf simple plain language read aloud',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out';
|
||||
import { fileStorage } from '@/lib/plugin-storage';
|
||||
import { clearHandoffs } from '@/lib/launch-handoff';
|
||||
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
|
||||
@@ -65,6 +66,8 @@ export interface SignOutCleanupOptions {
|
||||
* when an account that was dropped that way still has data here.
|
||||
*/
|
||||
keepLocalStore?: boolean;
|
||||
/** A forgotten PIN goes through this with the lock still up: it lifts the lock itself, last. */
|
||||
keepAppLock?: boolean;
|
||||
}
|
||||
|
||||
/** Run `cleanup` when everyone signs out in this tab. */
|
||||
@@ -145,6 +148,8 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void {
|
||||
}
|
||||
forgetOfflineShell();
|
||||
}
|
||||
// With no store left, the app lock has nothing to guard, and a next person shouldn't meet it.
|
||||
if (!options.keepLocalStore && !options.keepAppLock) void forgetAppLockAtSignOut();
|
||||
// Files and links on their way into a draft or an import.
|
||||
clearHandoffs();
|
||||
clearDesktopSurfaces();
|
||||
|
||||
+72
-3
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { StoreEvent, StoreScope } from '../area';
|
||||
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
|
||||
import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, STORE_OPENED_KEY, type WrapLayer } from './keys';
|
||||
import { forgetAccountKey, getAccountRecord, knownStoreFiles, listAccountRecords, openAccountKey, STORE_OPENED_KEY, type StoreKeyLayerMissingError, type WrapLayer } from './keys';
|
||||
import { StoreClient } from './store-client';
|
||||
|
||||
export interface StoreAccountInputs {
|
||||
@@ -33,6 +33,8 @@ export interface StorePageHooks {
|
||||
ownerTaken?(): Promise<void>;
|
||||
/** An account this device kept mail for has an empty store now: its key or its file went. */
|
||||
onStoreLost?(account: string): void;
|
||||
/** This tab became the owner and has no lock key: the app lock has to ask again. */
|
||||
onKeyLocked?(): void;
|
||||
}
|
||||
|
||||
/** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */
|
||||
@@ -53,6 +55,22 @@ export interface StoreBackend {
|
||||
remove(account: string): Promise<void>;
|
||||
/** Stops this tab's worker at once (a sign-out; the files go separately). */
|
||||
stop(): void;
|
||||
/**
|
||||
* App lock: closes the databases and stops this tab's worker, with every key it had.
|
||||
* Waits for a clean close at most `closeMs`. Until `unlock`, every call is refused.
|
||||
*/
|
||||
lock?(closeMs: number): Promise<void>;
|
||||
/** This tab has the key again: its worker starts first, and takes the store. */
|
||||
unlock?(ownerWaitMs: number): Promise<void>;
|
||||
ownsStoreHere?(): boolean;
|
||||
}
|
||||
|
||||
/** The app is locked: the store is closed, and its key isn't in memory anywhere. */
|
||||
export class StoreLockedError extends Error {
|
||||
constructor() {
|
||||
super('The local store is closed while the app is locked');
|
||||
this.name = 'StoreLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a call waits for the owner to have opened an account. */
|
||||
@@ -90,6 +108,8 @@ class PageStore implements StoreBackend {
|
||||
* can start the open again, and the second try finds the key the first one made.
|
||||
*/
|
||||
private readonly keyJustMade = new Set<string>();
|
||||
private locked = false;
|
||||
private ownerReady: (() => void) | null = null;
|
||||
/** False from taking over the store until this tab has heard the policy again. */
|
||||
private scopeCurrent = true;
|
||||
|
||||
@@ -100,6 +120,7 @@ class PageStore implements StoreBackend {
|
||||
}
|
||||
|
||||
private started(): StoreClient {
|
||||
if (this.locked) throw new StoreLockedError();
|
||||
if (this.client) return this.client;
|
||||
this.broken = false;
|
||||
const client = new StoreClient({
|
||||
@@ -112,6 +133,7 @@ class PageStore implements StoreBackend {
|
||||
this.hooks()?.onEvent(event);
|
||||
},
|
||||
onOwner: () => {
|
||||
this.ownerReady?.();
|
||||
this.opening.clear();
|
||||
const hooks = this.hooks();
|
||||
if (hooks?.ownerTaken) {
|
||||
@@ -144,13 +166,22 @@ class PageStore implements StoreBackend {
|
||||
this.broken = false;
|
||||
}
|
||||
|
||||
private async readKey(account: string): ReturnType<typeof openAccountKey> {
|
||||
try {
|
||||
return await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'StoreKeyLayerMissingError' && (error as StoreKeyLayerMissingError).layer === 'applock') this.hooks()?.onKeyLocked?.();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the owner may open: the key goes to this tab's own worker, never over the channel. */
|
||||
private openHere(account: string): Promise<void> {
|
||||
const running = this.opening.get(account);
|
||||
if (running) return running;
|
||||
const run = (async () => {
|
||||
const client = this.started();
|
||||
const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
|
||||
const { keyHex, record, fresh } = await this.readKey(account);
|
||||
if (fresh) this.keyJustMade.add(account);
|
||||
// The old file, if the key alone went, can never be read again.
|
||||
if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {});
|
||||
@@ -175,7 +206,44 @@ class PageStore implements StoreBackend {
|
||||
return run;
|
||||
}
|
||||
|
||||
async lock(closeMs: number): Promise<void> {
|
||||
this.locked = true;
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
this.opening.clear();
|
||||
this.keyJustMade.clear();
|
||||
if (!client) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
client.release().catch(() => {}),
|
||||
new Promise((resolve) => { timer = setTimeout(resolve, closeMs); }),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
// Past the wait a sync may still be running. Stopping the worker ends it, as a crash would.
|
||||
client.halt();
|
||||
}
|
||||
|
||||
async unlock(ownerWaitMs: number): Promise<void> {
|
||||
this.locked = false;
|
||||
if (this.client?.ownsStore || !StoreClient.supported()) return;
|
||||
if ((await listAccountRecords().catch(() => [])).length === 0) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, ownerWaitMs);
|
||||
this.ownerReady = () => {
|
||||
clearTimeout(timer);
|
||||
this.ownerReady = null;
|
||||
resolve();
|
||||
};
|
||||
this.started();
|
||||
});
|
||||
}
|
||||
|
||||
ownsStoreHere(): boolean {
|
||||
return this.client?.ownsStore ?? false;
|
||||
}
|
||||
|
||||
async ensureOpen(account: string, session?: StoreSession | null): Promise<void> {
|
||||
if (this.locked) throw new StoreLockedError();
|
||||
this.wanted.set(account, session ?? this.wanted.get(account) ?? null);
|
||||
const client = this.started();
|
||||
if (client.ownsStore) await this.openHere(account);
|
||||
@@ -196,6 +264,7 @@ class PageStore implements StoreBackend {
|
||||
|
||||
/** A call for an account: the owner may still be opening it, in this tab or another. */
|
||||
private async whenOpen<T>(account: string, call: (client: StoreClient) => Promise<T>): Promise<T> {
|
||||
if (this.locked) throw new StoreLockedError();
|
||||
if (this.broken) throw new Error('The local store could not start');
|
||||
await this.ensureOpen(account);
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
@@ -229,7 +298,7 @@ class PageStore implements StoreBackend {
|
||||
}
|
||||
|
||||
async setOnline(online: boolean): Promise<void> {
|
||||
if (!this.client || this.broken) return;
|
||||
if (this.locked || !this.client || this.broken) return;
|
||||
await this.client.setOnline(online);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,8 @@ export async function getAccountRecord(account: string): Promise<AccountRecord |
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAccountRecords(): Promise<AccountRecord[]> {
|
||||
export async function listAccountRecords(onlyIfThere = false): Promise<AccountRecord[]> {
|
||||
if (onlyIfThere && !(await keyDatabaseExists())) return [];
|
||||
return withDb((db) => promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as Promise<AccountRecord[]>);
|
||||
}
|
||||
|
||||
@@ -227,10 +228,17 @@ export class StoreKeyLayerMissingError extends Error {
|
||||
}
|
||||
|
||||
/** Deletes every key at sign-out. Works with no worker running, on any page. */
|
||||
export function deleteKeyDatabase(): void {
|
||||
try {
|
||||
indexedDB.deleteDatabase(DB_NAME);
|
||||
} catch {
|
||||
// Private mode or similar: nothing was stored either.
|
||||
}
|
||||
export function deleteKeyDatabase(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
// A connection still open goes on its own: withDb closes after each use.
|
||||
request.onblocked = () => resolve();
|
||||
} catch {
|
||||
// Private mode or similar: nothing was stored either.
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function deleteStorePool(): Promise<void> {
|
||||
* Started synchronously: the sign-out may navigate away right after.
|
||||
*/
|
||||
export function purgeStoreFiles(): Promise<void> {
|
||||
deleteKeyDatabase();
|
||||
const keysGone = deleteKeyDatabase();
|
||||
// Deleted on purpose: no account lost anything. The next open is a first one.
|
||||
try {
|
||||
localStorage.removeItem(STORE_OPENED_KEY);
|
||||
@@ -64,7 +64,8 @@ export function purgeStoreFiles(): Promise<void> {
|
||||
} catch {
|
||||
// No BroadcastChannel: no store either.
|
||||
}
|
||||
return deleteStorePool().catch(() => {
|
||||
const poolGone = deleteStorePool().catch(() => {
|
||||
// The key is gone, so what's left can't be read.
|
||||
});
|
||||
return Promise.all([keysGone, poolGone]).then(() => undefined);
|
||||
}
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "إغلاق"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "قفل التطبيق",
|
||||
"section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.",
|
||||
"enable_label": "القفل برمز PIN",
|
||||
"enable_desc": "متوقف افتراضيًا. يبقى رمز PIN على هذا الجهاز.",
|
||||
"protects_desc": "ما الذي يحميه هذا: في المتصفح، يمنع رمز PIN أي شخص يستخدم هذا الجهاز من دخول التطبيق ويبقي البريد المخزَّن هنا مشفّرًا. لا يمكنه إيقاف برنامج يعمل على هذا الحاسوب، أو إضافة متصفح ثبّتّها، أو شخص ينسخ ملفات المتصفح ولديه وقت لتجربة رموز كثيرة، لذا يستحق الرمز الأطول العناء. في تطبيق سطح المكتب يرتبط رمز PIN أيضًا بتسجيل دخولك على هذا الحاسوب. لا يتأثر حسابك على الخادم.",
|
||||
"enable_body": "اختر رمز PIN. ستحتاج إليه كلما قُفل التطبيق، وبعد إعادة التحميل أو بدء التشغيل من جديد.",
|
||||
"set_pin_title": "اختر رمز PIN",
|
||||
"change_pin": "تغيير رمز PIN…",
|
||||
"change_pin_title": "تغيير رمز PIN",
|
||||
"pin_label": "رمز PIN",
|
||||
"pin_current": "رمز PIN الحالي",
|
||||
"pin_new": "رمز PIN الجديد",
|
||||
"pin_repeat": "أعد إدخال رمز PIN",
|
||||
"pin_hint": "6 أحرف على الأقل. يمكن استخدام الحروف أيضًا، والرمز الأطول أصعب في التخمين.",
|
||||
"pin_obvious": "من السهل تخمين رمز PIN هذا. تجنّب التكرار والتسلسلات والاختيارات الشائعة.",
|
||||
"pin_mismatch": "الرمزان غير متطابقين.",
|
||||
"pin_recovery": "إذا نسيته، فالحل الوحيد هو إزالة البيانات من هذا الجهاز وتسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء",
|
||||
"save_failed": "لم ينجح ذلك. حاول مرة أخرى.",
|
||||
"timeout_label": "القفل بعد",
|
||||
"timeout_desc": "الوقت دون استخدام في أي نافذة أو علامة تبويب.",
|
||||
"lock_now": "القفل الآن",
|
||||
"lock_now_desc": "يقفل كل النوافذ وعلامات التبويب فورًا.",
|
||||
"disable_title": "إيقاف قفل التطبيق؟",
|
||||
"disable_body": "أدخل رمز PIN لإيقافه.",
|
||||
"disable_confirm": "إيقاف",
|
||||
"passkey_label": "مفتاح المرور",
|
||||
"passkey_desc": "افتح القفل بمفتاح مرور أو مفتاح أمان يستطيع حماية المفتاح. لا تستطيع كلها ذلك، ويبقى رمز PIN صالحًا.",
|
||||
"passkey_add": "إضافة مفتاح مرور…",
|
||||
"passkey_add_title": "إضافة مفتاح مرور",
|
||||
"passkey_remove": "إزالة",
|
||||
"passkey_added": "تمت إضافة مفتاح المرور.",
|
||||
"passkey_no_prf": "لا يستطيع مفتاح المرور هذا حماية المفتاح. يبقى رمز PIN صالحًا.",
|
||||
"passkey_failed": "لم يعمل مفتاح المرور. أدخل رمز PIN.",
|
||||
"use_passkey": "استخدام مفتاح مرور",
|
||||
"use_hello": "استخدام Windows Hello",
|
||||
"use_touchid": "استخدام Touch ID",
|
||||
"os_add": "تشغيل…",
|
||||
"os_add_title": "استخدام {method}",
|
||||
"os_remove": "إيقاف",
|
||||
"os_desc": "{method} فحص يجريه التطبيق. بعد الموافقة يقرأ التطبيق المفتاح من التخزين المشفر لهذا الحاسوب. يمكن لبرنامج يعمل تحت حساب المستخدم الخاص بك قراءته هناك دون {method}، لذلك يحمي رمز PIN أكثر. يبقى رمز PIN صالحًا.",
|
||||
"os_failed": "لم ينجح ذلك. أدخل رمز PIN.",
|
||||
"lock_title": "{app} مقفل",
|
||||
"locking": "جارٍ القفل…",
|
||||
"pin_prompt": "أدخل رمز PIN للمتابعة.",
|
||||
"unlock": "فتح القفل",
|
||||
"unlocking": "جارٍ فتح القفل…",
|
||||
"wrong_pin": "رمز PIN هذا غير صحيح.",
|
||||
"unlock_failed": "تعذر فتح القفل. حاول مرة أخرى.",
|
||||
"blocked_later": "محاولات كثيرة جدًا. حاول لاحقًا.",
|
||||
"forgot": "هل نسيت رمز PIN؟",
|
||||
"forgot_title": "إزالة البيانات المحلية وتسجيل الخروج؟",
|
||||
"forgot_body": "لا يمكن استعادة رمز PIN المنسي. يزيل هذا البريد المحفوظ على هذا الجهاز ويسجل خروج كل الحسابات لتتمكن من تسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.",
|
||||
"forgot_provider": "إذا سجّلت الدخول عبر صفحة تسجيل الدخول الخاصة بمؤسستك، فسيبقى ذلك الدخول مفتوحًا هناك. سيطلب تسجيل الدخول التالي هنا من تلك الصفحة دخولًا جديدًا حيثما تسمح بذلك.",
|
||||
"forgot_confirm": "إزالة وتسجيل الخروج",
|
||||
"unlock_in_main": "افتح القفل في النافذة الرئيسية.",
|
||||
"open_main": "الانتقال إلى النافذة الرئيسية",
|
||||
"setup_required_title": "تشترط مؤسستك قفل التطبيق",
|
||||
"setup_required_body": "اختر رمز PIN. ستحتاج إليه لفتح {app} بعد قفله.",
|
||||
"draft_saved_notice": "حُفظ ما كنت تكتبه في المسودات قبل القفل.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتان} few {# دقائق} many {# دقيقة} other {# دقيقة}}",
|
||||
"blocked_seconds": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# ثانية} one {ثانية واحدة} two {ثانيتين} few {# ثوانٍ} many {# ثانية} other {# ثانية}}.",
|
||||
"blocked_minutes": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "سجّل الدخول باستخدام المتصفح",
|
||||
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Tanca"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Bloqueig de l'app",
|
||||
"section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.",
|
||||
"enable_label": "Bloqueja amb un PIN",
|
||||
"enable_desc": "Desactivat per defecte. El PIN es queda en aquest dispositiu.",
|
||||
"protects_desc": "De què protegeix: en un navegador, el PIN impedeix que algú davant d'aquest dispositiu entri a l'aplicació i manté xifrat el correu desat aquí. No pot aturar un programa que s'executi en aquest ordinador, una extensió del navegador que hàgiu instal·lat o algú que copiï els fitxers del navegador i tingui temps de provar molts PIN, així que val la pena un PIN més llarg. A l'aplicació d'escriptori, el PIN també queda lligat al vostre inici de sessió en aquest ordinador. El vostre compte al servidor no es veu afectat.",
|
||||
"enable_body": "Trieu un PIN. El necessitareu cada vegada que l'app es bloquegi, i després de recarregar o tornar a iniciar.",
|
||||
"set_pin_title": "Trieu un PIN",
|
||||
"change_pin": "Canvia el PIN…",
|
||||
"change_pin_title": "Canvia el PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "PIN actual",
|
||||
"pin_new": "PIN nou",
|
||||
"pin_repeat": "Repetiu el PIN",
|
||||
"pin_hint": "Almenys 6 caràcters. També serveixen lletres, i un PIN més llarg és més difícil d'endevinar.",
|
||||
"pin_obvious": "Aquest PIN és massa fàcil d'endevinar. Eviteu repeticions, seqüències i opcions habituals.",
|
||||
"pin_mismatch": "Els dos PIN no coincideixen.",
|
||||
"pin_recovery": "Si l'oblideu, l'única sortida és eliminar les dades d'aquest dispositiu i tornar a iniciar la sessió. El correu al servidor es manté. Els missatges que encara esperen l'enviament en aquest dispositiu es perden.",
|
||||
"save": "Desa",
|
||||
"cancel": "Cancel·la",
|
||||
"save_failed": "No ha funcionat. Torneu-ho a provar.",
|
||||
"timeout_label": "Bloqueja després de",
|
||||
"timeout_desc": "Temps sense ús en qualsevol finestra o pestanya.",
|
||||
"lock_now": "Bloqueja ara",
|
||||
"lock_now_desc": "Bloqueja totes les finestres i pestanyes de cop.",
|
||||
"disable_title": "Voleu desactivar el bloqueig?",
|
||||
"disable_body": "Escriviu el PIN per desactivar-lo.",
|
||||
"disable_confirm": "Desactiva",
|
||||
"passkey_label": "Clau d'accés",
|
||||
"passkey_desc": "Desbloquegeu amb una clau d'accés o de seguretat que pugui protegir la clau. No totes poden, i el PIN continua funcionant.",
|
||||
"passkey_add": "Afegeix una clau d'accés…",
|
||||
"passkey_add_title": "Afegeix una clau d'accés",
|
||||
"passkey_remove": "Elimina",
|
||||
"passkey_added": "S'ha afegit la clau d'accés.",
|
||||
"passkey_no_prf": "Aquesta clau d'accés no pot protegir la clau. El PIN continua funcionant.",
|
||||
"passkey_failed": "La clau d'accés no ha funcionat. Escriviu el PIN.",
|
||||
"use_passkey": "Utilitza una clau d'accés",
|
||||
"use_hello": "Utilitza Windows Hello",
|
||||
"use_touchid": "Utilitza Touch ID",
|
||||
"os_add": "Activa…",
|
||||
"os_add_title": "Utilitza {method}",
|
||||
"os_remove": "Desactiva",
|
||||
"os_desc": "{method} és una comprovació de l'app. Després d'un sí, l'app llegeix la clau de l'emmagatzematge xifrat d'aquest ordinador. Un programa que s'executi amb el vostre compte d'usuari la podria llegir allà sense {method}, així que el PIN protegeix més. El PIN continua funcionant.",
|
||||
"os_failed": "No ha funcionat. Escriviu el PIN.",
|
||||
"lock_title": "{app} està bloquejada",
|
||||
"locking": "S'està bloquejant…",
|
||||
"pin_prompt": "Escriviu el PIN per continuar.",
|
||||
"unlock": "Desbloqueja",
|
||||
"unlocking": "S'està desbloquejant…",
|
||||
"wrong_pin": "Aquest PIN no és correcte.",
|
||||
"unlock_failed": "No s'ha pogut desbloquejar. Torneu-ho a provar.",
|
||||
"blocked_later": "Massa intents. Torneu-ho a provar més tard.",
|
||||
"forgot": "Heu oblidat el PIN?",
|
||||
"forgot_title": "Voleu eliminar les dades locals i tancar la sessió?",
|
||||
"forgot_body": "Un PIN oblidat no es pot recuperar. Això elimina el correu desat en aquest dispositiu i tanca la sessió de tots els comptes, perquè pugueu tornar a entrar. El correu al servidor es manté. Els missatges que encara esperen l'enviament en aquest dispositiu es perden.",
|
||||
"forgot_provider": "Si heu iniciat la sessió a través de la pàgina d'inici de sessió de la vostra organització, aquesta sessió hi continua oberta. El proper inici de sessió aquí demanarà a aquella pàgina una sessió nova, si ho permet.",
|
||||
"forgot_confirm": "Elimina i tanca la sessió",
|
||||
"unlock_in_main": "Desbloquegeu a la finestra principal.",
|
||||
"open_main": "Vés a la finestra principal",
|
||||
"setup_required_title": "La vostra organització exigeix un bloqueig de l'app",
|
||||
"setup_required_body": "Trieu un PIN. El necessitareu per obrir {app} quan es bloquegi.",
|
||||
"draft_saved_notice": "El que escrivíeu s'ha desat a Esborranys abans del bloqueig.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minut} other {# minuts}}",
|
||||
"blocked_seconds": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# segon} other {# segons}}.",
|
||||
"blocked_minutes": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# minut} other {# minuts}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Inicia la sessió amb el navegador",
|
||||
"browser_waiting": "Acaba d'iniciar la sessió al navegador",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Zavřít"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Zámek aplikace",
|
||||
"section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.",
|
||||
"enable_label": "Zamykat kódem PIN",
|
||||
"enable_desc": "Ve výchozím stavu vypnuto. PIN zůstává na tomto zařízení.",
|
||||
"protects_desc": "Před čím to chrání: v prohlížeči PIN nepustí do aplikace nikoho u tohoto zařízení a pošta uložená zde zůstává šifrovaná. Nezastaví program běžící na tomto počítači, rozšíření prohlížeče, které jste nainstalovali, ani někoho, kdo zkopíruje soubory prohlížeče a má čas zkoušet mnoho PINů, proto se delší PIN vyplatí. V desktopové aplikaci je PIN navíc svázán s vaším přihlášením na tomto počítači. Váš účet na serveru to neovlivní.",
|
||||
"enable_body": "Zvolte PIN. Budete ho potřebovat při každém zamčení aplikace a po načtení znovu nebo novém spuštění.",
|
||||
"set_pin_title": "Zvolte PIN",
|
||||
"change_pin": "Změnit PIN…",
|
||||
"change_pin_title": "Změnit PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "Současný PIN",
|
||||
"pin_new": "Nový PIN",
|
||||
"pin_repeat": "Zopakujte PIN",
|
||||
"pin_hint": "Alespoň 6 znaků. Fungují i písmena a delší PIN se hůř uhodne.",
|
||||
"pin_obvious": "Tento PIN se dá snadno uhodnout. Vyhněte se opakování, řadám a běžným volbám.",
|
||||
"pin_mismatch": "Oba kódy PIN nejsou stejné.",
|
||||
"pin_recovery": "Pokud ho zapomenete, jedinou cestou je odebrat data z tohoto zařízení a znovu se přihlásit. Vaše pošta na serveru zůstává. Zprávy čekající na odeslání na tomto zařízení se ztratí.",
|
||||
"save": "Uložit",
|
||||
"cancel": "Zrušit",
|
||||
"save_failed": "Nepodařilo se to. Zkuste to znovu.",
|
||||
"timeout_label": "Zamknout po",
|
||||
"timeout_desc": "Čas bez použití v kterémkoli okně nebo kartě.",
|
||||
"lock_now": "Zamknout teď",
|
||||
"lock_now_desc": "Zamkne hned všechna okna a karty.",
|
||||
"disable_title": "Vypnout zámek aplikace?",
|
||||
"disable_body": "Pro vypnutí zadejte PIN.",
|
||||
"disable_confirm": "Vypnout",
|
||||
"passkey_label": "Přístupový klíč",
|
||||
"passkey_desc": "Odemkněte přístupovým nebo bezpečnostním klíčem, který umí chránit klíč. Ne všechny to umí a PIN dál funguje.",
|
||||
"passkey_add": "Přidat přístupový klíč…",
|
||||
"passkey_add_title": "Přidat přístupový klíč",
|
||||
"passkey_remove": "Odebrat",
|
||||
"passkey_added": "Přístupový klíč byl přidán.",
|
||||
"passkey_no_prf": "Tento přístupový klíč nemůže chránit klíč. PIN dál funguje.",
|
||||
"passkey_failed": "Přístupový klíč nefungoval. Zadejte PIN.",
|
||||
"use_passkey": "Použít přístupový klíč",
|
||||
"use_hello": "Použít Windows Hello",
|
||||
"use_touchid": "Použít Touch ID",
|
||||
"os_add": "Zapnout…",
|
||||
"os_add_title": "Použít {method}",
|
||||
"os_remove": "Vypnout",
|
||||
"os_desc": "{method} je kontrola, kterou provádí aplikace. Po potvrzení aplikace přečte klíč ze šifrovaného úložiště tohoto počítače. Program běžící pod vaším uživatelským účtem by ho tam mohl přečíst i bez toho, takže PIN chrání víc. PIN dál funguje.",
|
||||
"os_failed": "Nepodařilo se to. Zadejte PIN.",
|
||||
"lock_title": "{app} je zamčená",
|
||||
"locking": "Zamykání…",
|
||||
"pin_prompt": "Pokračujte zadáním kódu PIN.",
|
||||
"unlock": "Odemknout",
|
||||
"unlocking": "Odemykání…",
|
||||
"wrong_pin": "Tento PIN není správný.",
|
||||
"unlock_failed": "Odemknutí se nepodařilo. Zkuste to znovu.",
|
||||
"blocked_later": "Příliš mnoho pokusů. Zkuste to později.",
|
||||
"forgot": "Zapomněli jste PIN?",
|
||||
"forgot_title": "Odebrat místní data a odhlásit se?",
|
||||
"forgot_body": "Zapomenutý PIN nelze obnovit. Tím se odebere pošta uložená na tomto zařízení a odhlásí se všechny účty, abyste se mohli znovu přihlásit. Vaše pošta na serveru zůstává. Zprávy čekající na odeslání na tomto zařízení se ztratí.",
|
||||
"forgot_provider": "Pokud jste se přihlásili přes přihlašovací stránku své organizace, zůstane tam přihlášení otevřené. Další přihlášení zde tuto stránku požádá o nové přihlášení, pokud to umožňuje.",
|
||||
"forgot_confirm": "Odebrat a odhlásit",
|
||||
"unlock_in_main": "Odemkněte v hlavním okně.",
|
||||
"open_main": "Přejít do hlavního okna",
|
||||
"setup_required_title": "Vaše organizace vyžaduje zámek aplikace",
|
||||
"setup_required_body": "Zvolte PIN. Budete ho potřebovat k otevření {app} po zamčení.",
|
||||
"draft_saved_notice": "To, co jste psali, se před zamčením uložilo do konceptů.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}",
|
||||
"blocked_seconds": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# sekundu} few {# sekundy} many {# sekundy} other {# sekund}}.",
|
||||
"blocked_minutes": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# minutu} few {# minuty} many {# minuty} other {# minut}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Přihlásit se v prohlížeči",
|
||||
"browser_waiting": "Dokončete přihlášení v prohlížeči",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Luk"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Applås",
|
||||
"section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.",
|
||||
"enable_label": "Lås med en pinkode",
|
||||
"enable_desc": "Slået fra som standard. Pinkoden bliver på denne enhed.",
|
||||
"protects_desc": "Hvad det beskytter mod: I en browser holder pinkoden andre ved denne enhed ude af appen og holder den mail, der er gemt her, krypteret. Den kan ikke stoppe et program på denne computer, en browserudvidelse, du har installeret, eller nogen, der kopierer browserens filer og har tid til at prøve mange pinkoder, så en længere pinkode er umagen værd. I desktop-appen er pinkoden også knyttet til dit login på denne computer. Din konto på serveren påvirkes ikke.",
|
||||
"enable_body": "Vælg en pinkode. Du skal bruge den, hver gang appen låser, og efter en genindlæsning eller ny start.",
|
||||
"set_pin_title": "Vælg en pinkode",
|
||||
"change_pin": "Skift pinkode…",
|
||||
"change_pin_title": "Skift pinkoden",
|
||||
"pin_label": "Pinkode",
|
||||
"pin_current": "Nuværende pinkode",
|
||||
"pin_new": "Ny pinkode",
|
||||
"pin_repeat": "Gentag pinkoden",
|
||||
"pin_hint": "Mindst 6 tegn. Bogstaver virker også, og en længere pinkode er sværere at gætte.",
|
||||
"pin_obvious": "Den pinkode er for nem at gætte. Undgå gentagelser, rækkefølger og almindelige valg.",
|
||||
"pin_mismatch": "De to pinkoder er ikke ens.",
|
||||
"pin_recovery": "Hvis du glemmer den, er eneste udvej at fjerne dataene på denne enhed og logge ind igen. Din mail på serveren bliver. Beskeder, der stadig venter på at blive sendt fra denne enhed, går tabt.",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller",
|
||||
"save_failed": "Det virkede ikke. Prøv igen.",
|
||||
"timeout_label": "Lås efter",
|
||||
"timeout_desc": "Tid uden brug i et vindue eller en fane.",
|
||||
"lock_now": "Lås nu",
|
||||
"lock_now_desc": "Låser alle vinduer og faner med det samme.",
|
||||
"disable_title": "Slå applåsen fra?",
|
||||
"disable_body": "Indtast din pinkode for at slå den fra.",
|
||||
"disable_confirm": "Slå fra",
|
||||
"passkey_label": "Adgangsnøgle",
|
||||
"passkey_desc": "Lås op med en adgangsnøgle eller sikkerhedsnøgle, der kan beskytte nøglen. Det kan ikke alle, og pinkoden virker stadig.",
|
||||
"passkey_add": "Tilføj en adgangsnøgle…",
|
||||
"passkey_add_title": "Tilføj en adgangsnøgle",
|
||||
"passkey_remove": "Fjern",
|
||||
"passkey_added": "Adgangsnøglen er tilføjet.",
|
||||
"passkey_no_prf": "Denne adgangsnøgle kan ikke beskytte nøglen. Din pinkode virker stadig.",
|
||||
"passkey_failed": "Adgangsnøglen virkede ikke. Indtast din pinkode.",
|
||||
"use_passkey": "Brug en adgangsnøgle",
|
||||
"use_hello": "Brug Windows Hello",
|
||||
"use_touchid": "Brug Touch ID",
|
||||
"os_add": "Slå til…",
|
||||
"os_add_title": "Brug {method}",
|
||||
"os_remove": "Slå fra",
|
||||
"os_desc": "{method} er en kontrol, appen foretager. Efter et ja læser appen nøglen fra computerens krypterede lager. Et program under din brugerkonto kan læse den der uden {method}, så pinkoden beskytter mere. Pinkoden virker stadig.",
|
||||
"os_failed": "Det virkede ikke. Indtast din pinkode.",
|
||||
"lock_title": "{app} er låst",
|
||||
"locking": "Låser…",
|
||||
"pin_prompt": "Indtast din pinkode for at fortsætte.",
|
||||
"unlock": "Lås op",
|
||||
"unlocking": "Låser op…",
|
||||
"wrong_pin": "Den pinkode er ikke rigtig.",
|
||||
"unlock_failed": "Oplåsning virkede ikke. Prøv igen.",
|
||||
"blocked_later": "For mange forsøg. Prøv igen senere.",
|
||||
"forgot": "Glemt din pinkode?",
|
||||
"forgot_title": "Fjern lokale data og log ud?",
|
||||
"forgot_body": "En glemt pinkode kan ikke gendannes. Dette fjerner mail gemt på denne enhed og logger alle konti ud, så du kan logge ind igen. Din mail på serveren bliver. Beskeder, der stadig venter på at blive sendt fra denne enhed, går tabt.",
|
||||
"forgot_provider": "Hvis du loggede ind via din organisations loginside, forbliver det login åbent dér. Næste login her beder den side om et nyt login, hvor det er muligt.",
|
||||
"forgot_confirm": "Fjern og log ud",
|
||||
"unlock_in_main": "Lås op i hovedvinduet.",
|
||||
"open_main": "Gå til hovedvinduet",
|
||||
"setup_required_title": "Din organisation kræver en applås",
|
||||
"setup_required_body": "Vælg en pinkode. Du skal bruge den for at åbne {app}, når den er låst.",
|
||||
"draft_saved_notice": "Det, du skrev, blev gemt i Kladder, før appen låste.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minut} other {# minutter}}",
|
||||
"blocked_seconds": "For mange forsøg. Prøv igen om {count, plural, one {# sekund} other {# sekunder}}.",
|
||||
"blocked_minutes": "For mange forsøg. Prøv igen om {count, plural, one {# minut} other {# minutter}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Log ind med din browser",
|
||||
"browser_waiting": "Gør login færdigt i din browser",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Schließen"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "App-Sperre",
|
||||
"section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.",
|
||||
"enable_label": "Mit PIN sperren",
|
||||
"enable_desc": "Standardmäßig aus. Die PIN bleibt auf diesem Gerät.",
|
||||
"protects_desc": "Wovor das schützt: Im Browser hält die PIN andere an diesem Gerät aus der App heraus, und die hier gespeicherten E-Mails bleiben verschlüsselt. Sie kann kein Programm auf diesem Computer aufhalten, keine von Ihnen installierte Browser-Erweiterung und niemanden, der die Dateien des Browsers kopiert und Zeit hat, viele PINs auszuprobieren. Eine längere PIN lohnt sich also. In der Desktop-App ist die PIN zusätzlich an Ihre Anmeldung an diesem Computer gebunden. Ihr Konto auf dem Server ist davon nicht betroffen.",
|
||||
"enable_body": "Wählen Sie eine PIN. Sie brauchen sie jedes Mal, wenn die App sperrt, und nach einem Neuladen oder Neustart.",
|
||||
"set_pin_title": "PIN wählen",
|
||||
"change_pin": "PIN ändern…",
|
||||
"change_pin_title": "PIN ändern",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "Aktuelle PIN",
|
||||
"pin_new": "Neue PIN",
|
||||
"pin_repeat": "PIN wiederholen",
|
||||
"pin_hint": "Mindestens 6 Zeichen. Buchstaben gehen auch, und eine längere PIN ist schwerer zu erraten.",
|
||||
"pin_obvious": "Diese PIN ist zu leicht zu erraten. Vermeiden Sie Wiederholungen, Zahlenfolgen und gängige Kombinationen.",
|
||||
"pin_mismatch": "Die beiden PINs sind nicht gleich.",
|
||||
"pin_recovery": "Wenn Sie sie vergessen, müssen Sie die Daten auf diesem Gerät entfernen und sich neu anmelden. Ihre E-Mails auf dem Server bleiben. Nachrichten, die auf diesem Gerät noch auf den Versand warten, gehen verloren.",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"save_failed": "Das hat nicht geklappt. Versuchen Sie es noch einmal.",
|
||||
"timeout_label": "Sperren nach",
|
||||
"timeout_desc": "Zeit ohne Nutzung in irgendeinem Fenster oder Tab.",
|
||||
"lock_now": "Jetzt sperren",
|
||||
"lock_now_desc": "Sperrt alle Fenster und Tabs sofort.",
|
||||
"disable_title": "App-Sperre ausschalten?",
|
||||
"disable_body": "Geben Sie Ihre PIN ein, um sie auszuschalten.",
|
||||
"disable_confirm": "Ausschalten",
|
||||
"passkey_label": "Passkey",
|
||||
"passkey_desc": "Entsperren mit einem Passkey oder Sicherheitsschlüssel, der den Schlüssel schützen kann. Das können nicht alle, und die PIN funktioniert weiter.",
|
||||
"passkey_add": "Passkey hinzufügen…",
|
||||
"passkey_add_title": "Passkey hinzufügen",
|
||||
"passkey_remove": "Entfernen",
|
||||
"passkey_added": "Passkey hinzugefügt.",
|
||||
"passkey_no_prf": "Dieser Passkey kann den Schlüssel nicht schützen. Ihre PIN funktioniert weiter.",
|
||||
"passkey_failed": "Der Passkey hat nicht funktioniert. Geben Sie stattdessen Ihre PIN ein.",
|
||||
"use_passkey": "Passkey verwenden",
|
||||
"use_hello": "Windows Hello verwenden",
|
||||
"use_touchid": "Touch ID verwenden",
|
||||
"os_add": "Einschalten…",
|
||||
"os_add_title": "{method} verwenden",
|
||||
"os_remove": "Ausschalten",
|
||||
"os_desc": "{method} ist eine Prüfung durch die App. Nach einem Ja liest die App den Schlüssel aus dem verschlüsselten Speicher dieses Computers. Ein Programm unter Ihrem Benutzerkonto könnte ihn dort auch ohne {method} lesen, die PIN schützt also mehr. Die PIN funktioniert weiter.",
|
||||
"os_failed": "Das hat nicht geklappt. Geben Sie stattdessen Ihre PIN ein.",
|
||||
"lock_title": "{app} ist gesperrt",
|
||||
"locking": "Wird gesperrt…",
|
||||
"pin_prompt": "Geben Sie Ihre PIN ein, um fortzufahren.",
|
||||
"unlock": "Entsperren",
|
||||
"unlocking": "Wird entsperrt…",
|
||||
"wrong_pin": "Diese PIN stimmt nicht.",
|
||||
"unlock_failed": "Das Entsperren hat nicht geklappt. Versuchen Sie es noch einmal.",
|
||||
"blocked_later": "Zu viele Versuche. Versuchen Sie es später noch einmal.",
|
||||
"forgot": "PIN vergessen?",
|
||||
"forgot_title": "Lokale Daten entfernen und abmelden?",
|
||||
"forgot_body": "Eine vergessene PIN lässt sich nicht wiederherstellen. Dies entfernt die E-Mails auf diesem Gerät und meldet alle Konten ab, damit Sie sich neu anmelden können. Ihre E-Mails auf dem Server bleiben. Nachrichten, die auf diesem Gerät noch auf den Versand warten, gehen verloren.",
|
||||
"forgot_provider": "Wenn Sie sich über die Anmeldeseite Ihrer Organisation angemeldet haben, bleibt diese Anmeldung dort offen. Die nächste Anmeldung hier fordert auf dieser Seite eine neue Anmeldung an, sofern sie das zulässt.",
|
||||
"forgot_confirm": "Entfernen und abmelden",
|
||||
"unlock_in_main": "Entsperren Sie im Hauptfenster.",
|
||||
"open_main": "Zum Hauptfenster",
|
||||
"setup_required_title": "Ihre Organisation verlangt eine App-Sperre",
|
||||
"setup_required_body": "Wählen Sie eine PIN. Sie brauchen sie, um {app} nach dem Sperren zu öffnen.",
|
||||
"draft_saved_notice": "Was Sie geschrieben haben, wurde vor dem Sperren in den Entwürfen gespeichert.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# Minute} other {# Minuten}}",
|
||||
"blocked_seconds": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Sekunde} other {# Sekunden}} noch einmal.",
|
||||
"blocked_minutes": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Minute} other {# Minuten}} noch einmal."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Mit dem Browser anmelden",
|
||||
"browser_waiting": "Schließen Sie die Anmeldung im Browser ab",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "App lock",
|
||||
"section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.",
|
||||
"enable_label": "Lock with a PIN",
|
||||
"enable_desc": "Off by default. The PIN stays on this device.",
|
||||
"protects_desc": "What this protects against: in a browser, the PIN keeps someone at this device out of the app and keeps the mail stored here encrypted. It can't stop a program running on this computer, a browser extension you installed, or someone who copies the browser's files and has time to try many PINs, so a longer PIN is worth it. In the desktop app the PIN is also tied to your login on this computer. Your account on the server isn't affected.",
|
||||
"enable_body": "Choose a PIN. You need it each time the app locks, and after a reload or a new start.",
|
||||
"set_pin_title": "Choose a PIN",
|
||||
"change_pin": "Change PIN…",
|
||||
"change_pin_title": "Change the PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "Current PIN",
|
||||
"pin_new": "New PIN",
|
||||
"pin_repeat": "Repeat the PIN",
|
||||
"pin_hint": "At least 6 characters. Letters work too, and a longer PIN is harder to guess.",
|
||||
"pin_obvious": "That PIN is too easy to guess. Avoid repeats, runs and common choices.",
|
||||
"pin_mismatch": "The two PINs are not the same.",
|
||||
"pin_recovery": "If you forget it, the only way back is to remove the data on this device and sign in again. Your mail on the server stays. Messages still waiting to be sent on this device are lost.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"save_failed": "That did not work. Try again.",
|
||||
"timeout_label": "Lock after",
|
||||
"timeout_desc": "Time without use in any window or tab.",
|
||||
"lock_now": "Lock now",
|
||||
"lock_now_desc": "Locks all windows and tabs at once.",
|
||||
"disable_title": "Turn off the app lock?",
|
||||
"disable_body": "Enter your PIN to turn it off.",
|
||||
"disable_confirm": "Turn off",
|
||||
"passkey_label": "Passkey",
|
||||
"passkey_desc": "Unlock with a passkey or security key that can protect the key. Not all can, and the PIN keeps working.",
|
||||
"passkey_add": "Add a passkey…",
|
||||
"passkey_add_title": "Add a passkey",
|
||||
"passkey_remove": "Remove",
|
||||
"passkey_added": "Passkey added.",
|
||||
"passkey_no_prf": "This passkey can't protect the key. Your PIN keeps working.",
|
||||
"passkey_failed": "The passkey did not work. Enter your PIN instead.",
|
||||
"use_passkey": "Use a passkey",
|
||||
"use_hello": "Use Windows Hello",
|
||||
"use_touchid": "Use Touch ID",
|
||||
"os_add": "Turn on…",
|
||||
"os_add_title": "Use {method}",
|
||||
"os_remove": "Turn off",
|
||||
"os_desc": "{method} is a check by the app. After a yes, the app reads the key from this computer's encrypted storage. A program running under your user account could read it there without {method}, so the PIN protects more. The PIN keeps working.",
|
||||
"os_failed": "That did not work. Enter your PIN instead.",
|
||||
"lock_title": "{app} is locked",
|
||||
"locking": "Locking…",
|
||||
"pin_prompt": "Enter your PIN to continue.",
|
||||
"unlock": "Unlock",
|
||||
"unlocking": "Unlocking…",
|
||||
"wrong_pin": "That PIN is not right.",
|
||||
"unlock_failed": "Unlocking did not work. Try again.",
|
||||
"blocked_later": "Too many tries. Try again later.",
|
||||
"forgot": "Forgot your PIN?",
|
||||
"forgot_title": "Remove local data and sign out?",
|
||||
"forgot_body": "A forgotten PIN can't be recovered. This removes the mail kept on this device and signs out all accounts, so you can sign in again. Your mail on the server stays. Messages still waiting to be sent on this device are lost.",
|
||||
"forgot_provider": "If you signed in through your organisation's login page, that login stays open there. The next sign-in here asks that page for a new login, where it allows that.",
|
||||
"forgot_confirm": "Remove and sign out",
|
||||
"unlock_in_main": "Unlock in the main window.",
|
||||
"open_main": "Go to the main window",
|
||||
"setup_required_title": "Your organisation requires an app lock",
|
||||
"setup_required_body": "Choose a PIN. You need it to open {app} after it locks.",
|
||||
"draft_saved_notice": "What you were writing was saved to Drafts before the app locked.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minute} other {# minutes}}",
|
||||
"blocked_seconds": "Too many tries. Try again in {count, plural, one {# second} other {# seconds}}.",
|
||||
"blocked_minutes": "Too many tries. Try again in {count, plural, one {# minute} other {# minutes}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Sign in with your browser",
|
||||
"browser_waiting": "Finish signing in in your browser",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Cerrar"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Bloqueo de la app",
|
||||
"section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.",
|
||||
"enable_label": "Bloquear con un PIN",
|
||||
"enable_desc": "Desactivado de forma predeterminada. El PIN se queda en este dispositivo.",
|
||||
"protects_desc": "De qué protege: en un navegador, el PIN impide que alguien frente a este dispositivo entre en la aplicación y mantiene cifrado el correo guardado aquí. No puede detener un programa que se ejecute en este ordenador, una extensión del navegador que hayas instalado ni a alguien que copie los archivos del navegador y tenga tiempo de probar muchos PIN, así que merece la pena un PIN más largo. En la aplicación de escritorio, el PIN también queda ligado a tu inicio de sesión en este ordenador. Tu cuenta en el servidor no se ve afectada.",
|
||||
"enable_body": "Elija un PIN. Lo necesitará cada vez que la app se bloquee, y tras recargar o volver a iniciar.",
|
||||
"set_pin_title": "Elegir un PIN",
|
||||
"change_pin": "Cambiar PIN…",
|
||||
"change_pin_title": "Cambiar el PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "PIN actual",
|
||||
"pin_new": "PIN nuevo",
|
||||
"pin_repeat": "Repetir el PIN",
|
||||
"pin_hint": "Al menos 6 caracteres. También valen letras, y un PIN más largo es más difícil de adivinar.",
|
||||
"pin_obvious": "Ese PIN es demasiado fácil de adivinar. Evita repeticiones, secuencias y opciones habituales.",
|
||||
"pin_mismatch": "Los dos PIN no coinciden.",
|
||||
"pin_recovery": "Si lo olvida, la única salida es eliminar los datos de este dispositivo y volver a iniciar sesión. Su correo en el servidor se mantiene. Los mensajes que aún esperan envío en este dispositivo se pierden.",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"save_failed": "No ha funcionado. Inténtelo de nuevo.",
|
||||
"timeout_label": "Bloquear tras",
|
||||
"timeout_desc": "Tiempo sin uso en cualquier ventana o pestaña.",
|
||||
"lock_now": "Bloquear ahora",
|
||||
"lock_now_desc": "Bloquea todas las ventanas y pestañas a la vez.",
|
||||
"disable_title": "¿Desactivar el bloqueo?",
|
||||
"disable_body": "Escriba su PIN para desactivarlo.",
|
||||
"disable_confirm": "Desactivar",
|
||||
"passkey_label": "Llave de acceso",
|
||||
"passkey_desc": "Desbloquee con una llave de acceso o de seguridad que pueda proteger la clave. No todas pueden, y el PIN sigue funcionando.",
|
||||
"passkey_add": "Añadir una llave de acceso…",
|
||||
"passkey_add_title": "Añadir una llave de acceso",
|
||||
"passkey_remove": "Quitar",
|
||||
"passkey_added": "Llave de acceso añadida.",
|
||||
"passkey_no_prf": "Esta llave de acceso no puede proteger la clave. Su PIN sigue funcionando.",
|
||||
"passkey_failed": "La llave de acceso no ha funcionado. Escriba su PIN.",
|
||||
"use_passkey": "Usar una llave de acceso",
|
||||
"use_hello": "Usar Windows Hello",
|
||||
"use_touchid": "Usar Touch ID",
|
||||
"os_add": "Activar…",
|
||||
"os_add_title": "Usar {method}",
|
||||
"os_remove": "Desactivar",
|
||||
"os_desc": "{method} es una comprobación de la app. Tras un sí, la app lee la clave del almacenamiento cifrado de este equipo. Un programa que se ejecute con su cuenta de usuario podría leerla allí sin {method}, así que el PIN protege más. El PIN sigue funcionando.",
|
||||
"os_failed": "No ha funcionado. Escriba su PIN.",
|
||||
"lock_title": "{app} está bloqueada",
|
||||
"locking": "Bloqueando…",
|
||||
"pin_prompt": "Escriba su PIN para continuar.",
|
||||
"unlock": "Desbloquear",
|
||||
"unlocking": "Desbloqueando…",
|
||||
"wrong_pin": "Ese PIN no es correcto.",
|
||||
"unlock_failed": "No se ha podido desbloquear. Inténtelo de nuevo.",
|
||||
"blocked_later": "Demasiados intentos. Inténtelo más tarde.",
|
||||
"forgot": "¿Ha olvidado su PIN?",
|
||||
"forgot_title": "¿Eliminar los datos locales y cerrar sesión?",
|
||||
"forgot_body": "Un PIN olvidado no se puede recuperar. Esto elimina el correo guardado en este dispositivo y cierra la sesión de todas las cuentas, para que pueda volver a entrar. Su correo en el servidor se mantiene. Los mensajes que aún esperan envío en este dispositivo se pierden.",
|
||||
"forgot_provider": "Si iniciaste sesión a través de la página de tu organización, esa sesión sigue abierta allí. El próximo inicio de sesión aquí pedirá a esa página uno nuevo, si lo permite.",
|
||||
"forgot_confirm": "Eliminar y cerrar sesión",
|
||||
"unlock_in_main": "Desbloquee en la ventana principal.",
|
||||
"open_main": "Ir a la ventana principal",
|
||||
"setup_required_title": "Su organización exige un bloqueo de la app",
|
||||
"setup_required_body": "Elija un PIN. Lo necesitará para abrir {app} cuando se bloquee.",
|
||||
"draft_saved_notice": "Lo que estaba escribiendo se guardó en Borradores antes del bloqueo.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuto} other {# minutos}}",
|
||||
"blocked_seconds": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# segundo} other {# segundos}}.",
|
||||
"blocked_minutes": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# minuto} other {# minutos}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Iniciar sesión con el navegador",
|
||||
"browser_waiting": "Termina de iniciar sesión en el navegador",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Sulge"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Rakenduse lukk",
|
||||
"section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.",
|
||||
"enable_label": "Lukusta PIN-koodiga",
|
||||
"enable_desc": "Vaikimisi väljas. PIN-kood jääb sellesse seadmesse.",
|
||||
"protects_desc": "Mille eest see kaitseb: brauseris hoiab PIN-kood selle seadme taga olija rakendusest eemal ja siia salvestatud kirjad krüpteerituna. See ei peata selles arvutis töötavat programmi, sinu paigaldatud brauserilaiendit ega kedagi, kes kopeerib brauseri failid ja saab rahulikult palju PIN-koode proovida, seega tasub valida pikem kood. Töölauarakenduses on PIN-kood seotud ka sinu sisselogimisega selles arvutis. Sinu kontot serveris see ei mõjuta.",
|
||||
"enable_body": "Valige PIN-kood. Seda on vaja iga kord, kui rakendus lukustub, ja pärast uuesti laadimist või käivitamist.",
|
||||
"set_pin_title": "Valige PIN-kood",
|
||||
"change_pin": "Muuda PIN-koodi…",
|
||||
"change_pin_title": "Muuda PIN-koodi",
|
||||
"pin_label": "PIN-kood",
|
||||
"pin_current": "Praegune PIN-kood",
|
||||
"pin_new": "Uus PIN-kood",
|
||||
"pin_repeat": "Korrake PIN-koodi",
|
||||
"pin_hint": "Vähemalt 6 märki. Sobivad ka tähed ja pikemat PIN-koodi on raskem ära arvata.",
|
||||
"pin_obvious": "Seda PIN-koodi on liiga lihtne ära arvata. Väldi kordusi, järjestusi ja tavalisi valikuid.",
|
||||
"pin_mismatch": "Need kaks PIN-koodi ei ole samad.",
|
||||
"pin_recovery": "Kui selle unustate, on ainus võimalus kustutada selle seadme andmed ja uuesti sisse logida. Teie e-post serveris jääb alles. Selles seadmes saatmist ootavad kirjad lähevad kaotsi.",
|
||||
"save": "Salvesta",
|
||||
"cancel": "Tühista",
|
||||
"save_failed": "See ei õnnestunud. Proovige uuesti.",
|
||||
"timeout_label": "Lukusta pärast",
|
||||
"timeout_desc": "Kasutamata aeg mis tahes aknas või vahekaardil.",
|
||||
"lock_now": "Lukusta kohe",
|
||||
"lock_now_desc": "Lukustab kõik aknad ja vahekaardid korraga.",
|
||||
"disable_title": "Kas lülitada rakenduse lukk välja?",
|
||||
"disable_body": "Väljalülitamiseks sisestage PIN-kood.",
|
||||
"disable_confirm": "Lülita välja",
|
||||
"passkey_label": "Pääsukood",
|
||||
"passkey_desc": "Avage pääsukoodi või turvavõtmega, mis suudab võtit kaitsta. Kõik ei suuda ja PIN-kood töötab edasi.",
|
||||
"passkey_add": "Lisa pääsukood…",
|
||||
"passkey_add_title": "Lisa pääsukood",
|
||||
"passkey_remove": "Eemalda",
|
||||
"passkey_added": "Pääsukood on lisatud.",
|
||||
"passkey_no_prf": "See pääsukood ei suuda võtit kaitsta. PIN-kood töötab edasi.",
|
||||
"passkey_failed": "Pääsukood ei töötanud. Sisestage PIN-kood.",
|
||||
"use_passkey": "Kasuta pääsukoodi",
|
||||
"use_hello": "Kasuta Windows Hellot",
|
||||
"use_touchid": "Kasuta Touch ID-d",
|
||||
"os_add": "Lülita sisse…",
|
||||
"os_add_title": "Kasuta: {method}",
|
||||
"os_remove": "Lülita välja",
|
||||
"os_desc": "{method} on rakenduse tehtav kontroll. Pärast jah-vastust loeb rakendus võtme selle arvuti krüpteeritud hoidlast. Teie kasutajakonto all töötav programm võiks selle sealt lugeda ka ilma, seega kaitseb PIN-kood rohkem. PIN-kood töötab edasi.",
|
||||
"os_failed": "See ei õnnestunud. Sisestage PIN-kood.",
|
||||
"lock_title": "{app} on lukus",
|
||||
"locking": "Lukustamine…",
|
||||
"pin_prompt": "Jätkamiseks sisestage PIN-kood.",
|
||||
"unlock": "Ava",
|
||||
"unlocking": "Avamine…",
|
||||
"wrong_pin": "See PIN-kood ei ole õige.",
|
||||
"unlock_failed": "Avamine ei õnnestunud. Proovige uuesti.",
|
||||
"blocked_later": "Liiga palju katseid. Proovige hiljem uuesti.",
|
||||
"forgot": "Unustasite PIN-koodi?",
|
||||
"forgot_title": "Kas eemaldada kohalikud andmed ja logida välja?",
|
||||
"forgot_body": "Unustatud PIN-koodi ei saa taastada. See eemaldab selles seadmes hoitud e-posti ja logib kõik kontod välja, et saaksite uuesti sisse logida. Teie e-post serveris jääb alles. Selles seadmes saatmist ootavad kirjad lähevad kaotsi.",
|
||||
"forgot_provider": "Kui logisid sisse oma organisatsiooni sisselogimislehe kaudu, jääb see sisselogimine seal avatuks. Järgmine sisselogimine siin küsib sellelt lehelt uut sisselogimist, kui see on lubatud.",
|
||||
"forgot_confirm": "Eemalda ja logi välja",
|
||||
"unlock_in_main": "Avage põhiaknas.",
|
||||
"open_main": "Mine põhiaknasse",
|
||||
"setup_required_title": "Teie organisatsioon nõuab rakenduse lukku",
|
||||
"setup_required_body": "Valige PIN-kood. Seda on vaja {app} avamiseks pärast lukustumist.",
|
||||
"draft_saved_notice": "Kirjutatu salvestati enne lukustumist mustanditesse.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuti} other {# minuti}}",
|
||||
"blocked_seconds": "Liiga palju katseid. Proovige uuesti {count, plural, one {# sekundi} other {# sekundi}} pärast.",
|
||||
"blocked_minutes": "Liiga palju katseid. Proovige uuesti {count, plural, one {# minuti} other {# minuti}} pärast."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Logi sisse brauseriga",
|
||||
"browser_waiting": "Lõpeta sisselogimine brauseris",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "بستن"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "قفل برنامه",
|
||||
"section_desc": "پس از مدتی بیاستفاده، در هر پنجره و زبانه پین میخواهد. تا وقتی قفل است، ایمیلهای این دستگاه رمزگذاریشده میمانند و برنامه کلید آنها را ندارد.",
|
||||
"enable_label": "قفل با پین",
|
||||
"enable_desc": "بهطور پیشفرض خاموش است. پین روی همین دستگاه میماند.",
|
||||
"protects_desc": "در برابر چه چیزی محافظت میکند: در مرورگر، پین نمیگذارد کسی که پای این دستگاه است وارد برنامه شود و ایمیلهای ذخیرهشده در اینجا را رمزگذاریشده نگه میدارد. جلوی برنامهای که روی این رایانه اجرا میشود، افزونه مرورگری که نصب کردهاید، یا کسی که فایلهای مرورگر را کپی میکند و وقت امتحان کردن پینهای زیادی دارد را نمیگیرد؛ پس پین طولانیتر ارزشش را دارد. در برنامه دسکتاپ، پین به ورود شما به این رایانه هم وابسته است. حساب شما روی سرور تغییری نمیکند.",
|
||||
"enable_body": "یک پین انتخاب کنید. هر بار که برنامه قفل شود و پس از بارگذاری دوباره یا شروع تازه به آن نیاز دارید.",
|
||||
"set_pin_title": "انتخاب پین",
|
||||
"change_pin": "تغییر پین…",
|
||||
"change_pin_title": "تغییر پین",
|
||||
"pin_label": "پین",
|
||||
"pin_current": "پین فعلی",
|
||||
"pin_new": "پین جدید",
|
||||
"pin_repeat": "تکرار پین",
|
||||
"pin_hint": "دستکم ۶ نویسه. حروف هم پذیرفته میشوند و حدس زدن پین بلندتر سختتر است.",
|
||||
"pin_obvious": "حدس زدن این پین خیلی آسان است. از تکرار، توالی و انتخابهای رایج پرهیز کنید.",
|
||||
"pin_mismatch": "این دو پین یکسان نیستند.",
|
||||
"pin_recovery": "اگر آن را فراموش کنید، تنها راه حذف دادههای این دستگاه و ورود دوباره است. ایمیلهای شما روی سرور میمانند. پیامهایی که هنوز در این دستگاه منتظر ارسالاند از بین میروند.",
|
||||
"save": "ذخیره",
|
||||
"cancel": "لغو",
|
||||
"save_failed": "انجام نشد. دوباره تلاش کنید.",
|
||||
"timeout_label": "قفل پس از",
|
||||
"timeout_desc": "زمان بیاستفاده در هر پنجره یا زبانه.",
|
||||
"lock_now": "اکنون قفل کن",
|
||||
"lock_now_desc": "همه پنجرهها و زبانهها را فوراً قفل میکند.",
|
||||
"disable_title": "قفل برنامه خاموش شود؟",
|
||||
"disable_body": "برای خاموش کردن، پین خود را وارد کنید.",
|
||||
"disable_confirm": "خاموش کردن",
|
||||
"passkey_label": "کلید عبور",
|
||||
"passkey_desc": "با کلید عبور یا کلید امنیتی که بتواند از کلید محافظت کند باز کنید. همه نمیتوانند و پین همچنان کار میکند.",
|
||||
"passkey_add": "افزودن کلید عبور…",
|
||||
"passkey_add_title": "افزودن کلید عبور",
|
||||
"passkey_remove": "حذف",
|
||||
"passkey_added": "کلید عبور افزوده شد.",
|
||||
"passkey_no_prf": "این کلید عبور نمیتواند از کلید محافظت کند. پین شما همچنان کار میکند.",
|
||||
"passkey_failed": "کلید عبور کار نکرد. پین خود را وارد کنید.",
|
||||
"use_passkey": "استفاده از کلید عبور",
|
||||
"use_hello": "استفاده از Windows Hello",
|
||||
"use_touchid": "استفاده از Touch ID",
|
||||
"os_add": "روشن کردن…",
|
||||
"os_add_title": "استفاده از {method}",
|
||||
"os_remove": "خاموش کردن",
|
||||
"os_desc": "{method} بررسیای است که برنامه انجام میدهد. پس از تأیید، برنامه کلید را از فضای رمزگذاریشده این رایانه میخواند. برنامهای که زیر حساب کاربری شما اجرا شود میتواند آن را بدون {method} هم بخواند، پس پین محافظت بیشتری دارد. پین همچنان کار میکند.",
|
||||
"os_failed": "انجام نشد. پین خود را وارد کنید.",
|
||||
"lock_title": "{app} قفل است",
|
||||
"locking": "در حال قفل کردن…",
|
||||
"pin_prompt": "برای ادامه، پین خود را وارد کنید.",
|
||||
"unlock": "باز کردن قفل",
|
||||
"unlocking": "در حال باز کردن قفل…",
|
||||
"wrong_pin": "این پین درست نیست.",
|
||||
"unlock_failed": "باز کردن قفل انجام نشد. دوباره تلاش کنید.",
|
||||
"blocked_later": "تلاشهای زیاد. بعداً دوباره تلاش کنید.",
|
||||
"forgot": "پین خود را فراموش کردهاید؟",
|
||||
"forgot_title": "دادههای محلی حذف و از حساب خارج شوید؟",
|
||||
"forgot_body": "پین فراموششده بازیابی نمیشود. این کار ایمیلهای نگهداشتهشده در این دستگاه را حذف میکند و از همه حسابها خارج میشود تا بتوانید دوباره وارد شوید. ایمیلهای شما روی سرور میمانند. پیامهایی که هنوز در این دستگاه منتظر ارسالاند از بین میروند.",
|
||||
"forgot_provider": "اگر از طریق صفحه ورود سازمان خود وارد شدهاید، آن ورود همانجا باز میماند. ورود بعدی در اینجا، در صورت امکان، از آن صفحه ورود تازهای میخواهد.",
|
||||
"forgot_confirm": "حذف و خروج",
|
||||
"unlock_in_main": "در پنجره اصلی قفل را باز کنید.",
|
||||
"open_main": "رفتن به پنجره اصلی",
|
||||
"setup_required_title": "سازمان شما قفل برنامه را الزامی کرده است",
|
||||
"setup_required_body": "یک پین انتخاب کنید. برای باز کردن {app} پس از قفل شدن به آن نیاز دارید.",
|
||||
"draft_saved_notice": "آنچه مینوشتید پیش از قفل شدن در پیشنویسها ذخیره شد.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# دقیقه} other {# دقیقه}}",
|
||||
"blocked_seconds": "تلاشهای زیاد. {count, plural, one {# ثانیه} other {# ثانیه}} دیگر دوباره تلاش کنید.",
|
||||
"blocked_minutes": "تلاشهای زیاد. {count, plural, one {# دقیقه} other {# دقیقه}} دیگر دوباره تلاش کنید."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ورود با مرورگر",
|
||||
"browser_waiting": "ورود را در مرورگر کامل کنید",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Fermer"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Verrouillage de l'app",
|
||||
"section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.",
|
||||
"enable_label": "Verrouiller avec un code PIN",
|
||||
"enable_desc": "Désactivé par défaut. Le code PIN reste sur cet appareil.",
|
||||
"protects_desc": "Contre quoi cela protège : dans un navigateur, le code PIN empêche une personne devant cet appareil d'entrer dans l'application et garde chiffrés les messages stockés ici. Il ne peut pas arrêter un programme exécuté sur cet ordinateur, une extension de navigateur que vous avez installée, ni quelqu'un qui copie les fichiers du navigateur et a le temps d'essayer de nombreux codes : un code plus long en vaut donc la peine. Dans l'application de bureau, le code PIN est aussi lié à votre session sur cet ordinateur. Votre compte sur le serveur n'est pas concerné.",
|
||||
"enable_body": "Choisissez un code PIN. Il vous sera demandé à chaque verrouillage, et après un rechargement ou un nouveau démarrage.",
|
||||
"set_pin_title": "Choisir un code PIN",
|
||||
"change_pin": "Changer le code PIN…",
|
||||
"change_pin_title": "Changer le code PIN",
|
||||
"pin_label": "Code PIN",
|
||||
"pin_current": "Code PIN actuel",
|
||||
"pin_new": "Nouveau code PIN",
|
||||
"pin_repeat": "Répéter le code PIN",
|
||||
"pin_hint": "Au moins 6 caractères. Les lettres sont acceptées, et un code plus long est plus difficile à deviner.",
|
||||
"pin_obvious": "Ce code PIN est trop facile à deviner. Évitez les répétitions, les suites et les choix courants.",
|
||||
"pin_mismatch": "Les deux codes PIN ne sont pas identiques.",
|
||||
"pin_recovery": "Si vous l'oubliez, la seule solution est de supprimer les données de cet appareil et de vous reconnecter. Vos e-mails sur le serveur restent. Les messages encore en attente d'envoi sur cet appareil sont perdus.",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"save_failed": "Cela n'a pas fonctionné. Réessayez.",
|
||||
"timeout_label": "Verrouiller après",
|
||||
"timeout_desc": "Temps sans utilisation dans une fenêtre ou un onglet.",
|
||||
"lock_now": "Verrouiller maintenant",
|
||||
"lock_now_desc": "Verrouille toutes les fenêtres et tous les onglets immédiatement.",
|
||||
"disable_title": "Désactiver le verrouillage ?",
|
||||
"disable_body": "Saisissez votre code PIN pour le désactiver.",
|
||||
"disable_confirm": "Désactiver",
|
||||
"passkey_label": "Clé d'accès",
|
||||
"passkey_desc": "Déverrouillez avec une clé d'accès ou une clé de sécurité capable de protéger la clé. Toutes n'en sont pas capables, et le code PIN continue de fonctionner.",
|
||||
"passkey_add": "Ajouter une clé d'accès…",
|
||||
"passkey_add_title": "Ajouter une clé d'accès",
|
||||
"passkey_remove": "Supprimer",
|
||||
"passkey_added": "Clé d'accès ajoutée.",
|
||||
"passkey_no_prf": "Cette clé d'accès ne peut pas protéger la clé. Votre code PIN continue de fonctionner.",
|
||||
"passkey_failed": "La clé d'accès n'a pas fonctionné. Saisissez plutôt votre code PIN.",
|
||||
"use_passkey": "Utiliser une clé d'accès",
|
||||
"use_hello": "Utiliser Windows Hello",
|
||||
"use_touchid": "Utiliser Touch ID",
|
||||
"os_add": "Activer…",
|
||||
"os_add_title": "Utiliser {method}",
|
||||
"os_remove": "Désactiver",
|
||||
"os_desc": "{method} est une vérification faite par l'app. Après un oui, l'app lit la clé dans le stockage chiffré de cet ordinateur. Un programme lancé sous votre compte utilisateur pourrait l'y lire sans {method} : le code PIN protège donc davantage. Le code PIN continue de fonctionner.",
|
||||
"os_failed": "Cela n'a pas fonctionné. Saisissez plutôt votre code PIN.",
|
||||
"lock_title": "{app} est verrouillé",
|
||||
"locking": "Verrouillage…",
|
||||
"pin_prompt": "Saisissez votre code PIN pour continuer.",
|
||||
"unlock": "Déverrouiller",
|
||||
"unlocking": "Déverrouillage…",
|
||||
"wrong_pin": "Ce code PIN n'est pas correct.",
|
||||
"unlock_failed": "Le déverrouillage n'a pas fonctionné. Réessayez.",
|
||||
"blocked_later": "Trop d'essais. Réessayez plus tard.",
|
||||
"forgot": "Code PIN oublié ?",
|
||||
"forgot_title": "Supprimer les données locales et se déconnecter ?",
|
||||
"forgot_body": "Un code PIN oublié ne peut pas être récupéré. Cela supprime les e-mails gardés sur cet appareil et déconnecte tous les comptes, pour que vous puissiez vous reconnecter. Vos e-mails sur le serveur restent. Les messages encore en attente d'envoi sur cet appareil sont perdus.",
|
||||
"forgot_provider": "Si vous vous êtes connecté via la page de connexion de votre organisation, cette connexion y reste ouverte. La prochaine connexion ici demandera à cette page une nouvelle connexion, si elle le permet.",
|
||||
"forgot_confirm": "Supprimer et se déconnecter",
|
||||
"unlock_in_main": "Déverrouillez dans la fenêtre principale.",
|
||||
"open_main": "Aller à la fenêtre principale",
|
||||
"setup_required_title": "Votre organisation exige un verrouillage de l'app",
|
||||
"setup_required_body": "Choisissez un code PIN. Il vous faudra pour ouvrir {app} après le verrouillage.",
|
||||
"draft_saved_notice": "Ce que vous écriviez a été enregistré dans les brouillons avant le verrouillage.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minute} other {# minutes}}",
|
||||
"blocked_seconds": "Trop d'essais. Réessayez dans {count, plural, one {# seconde} other {# secondes}}.",
|
||||
"blocked_minutes": "Trop d'essais. Réessayez dans {count, plural, one {# minute} other {# minutes}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Se connecter avec le navigateur",
|
||||
"browser_waiting": "Terminez la connexion dans votre navigateur",
|
||||
|
||||
@@ -6763,6 +6763,74 @@
|
||||
"close": "סגירה"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "נעילת אפליקציה",
|
||||
"section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.",
|
||||
"enable_label": "נעילה עם קוד PIN",
|
||||
"enable_desc": "כבוי כברירת מחדל. קוד ה-PIN נשאר במכשיר הזה.",
|
||||
"protects_desc": "ממה זה מגן: בדפדפן, קוד ה-PIN מונע ממי שנמצא ליד המכשיר להיכנס לאפליקציה ושומר על הדואר המאוחסן כאן מוצפן. הוא לא יכול לעצור תוכנה שרצה במחשב הזה, תוסף דפדפן שהתקנתם, או מי שמעתיק את קובצי הדפדפן ויש לו זמן לנסות קודים רבים, ולכן כדאי קוד ארוך יותר. באפליקציית שולחן העבודה הקוד קשור גם להתחברות שלכם למחשב הזה. החשבון שלכם בשרת לא מושפע.",
|
||||
"enable_body": "בחרו קוד PIN. תצטרכו אותו בכל פעם שהאפליקציה ננעלת, ואחרי טעינה מחדש או הפעלה חדשה.",
|
||||
"set_pin_title": "בחירת קוד PIN",
|
||||
"change_pin": "שינוי קוד PIN…",
|
||||
"change_pin_title": "שינוי קוד PIN",
|
||||
"pin_label": "קוד PIN",
|
||||
"pin_current": "קוד PIN נוכחי",
|
||||
"pin_new": "קוד PIN חדש",
|
||||
"pin_repeat": "חזרה על קוד PIN",
|
||||
"pin_hint": "לפחות 6 תווים. אפשר גם אותיות, וקוד ארוך יותר קשה יותר לניחוש.",
|
||||
"pin_obvious": "קל מדי לנחש את קוד ה-PIN הזה. הימנעו מחזרות, רצפים ובחירות נפוצות.",
|
||||
"pin_mismatch": "שני הקודים אינם זהים.",
|
||||
"pin_recovery": "אם תשכחו אותו, הדרך היחידה היא להסיר את הנתונים מהמכשיר הזה ולהיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.",
|
||||
"save": "שמירה",
|
||||
"cancel": "ביטול",
|
||||
"save_failed": "זה לא הצליח. נסו שוב.",
|
||||
"timeout_label": "נעילה אחרי",
|
||||
"timeout_desc": "זמן ללא שימוש בכל חלון או כרטיסייה.",
|
||||
"lock_now": "נעילה עכשיו",
|
||||
"lock_now_desc": "נועל מיד את כל החלונות והכרטיסיות.",
|
||||
"disable_title": "לכבות את נעילת האפליקציה?",
|
||||
"disable_body": "הזינו את קוד ה-PIN כדי לכבות אותה.",
|
||||
"disable_confirm": "כיבוי",
|
||||
"passkey_label": "מפתח גישה",
|
||||
"passkey_desc": "פתיחה עם מפתח גישה או מפתח אבטחה שיכול להגן על המפתח. לא כולם יכולים, וקוד ה-PIN ממשיך לעבוד.",
|
||||
"passkey_add": "הוספת מפתח גישה…",
|
||||
"passkey_add_title": "הוספת מפתח גישה",
|
||||
"passkey_remove": "הסרה",
|
||||
"passkey_added": "מפתח הגישה נוסף.",
|
||||
"passkey_no_prf": "מפתח הגישה הזה לא יכול להגן על המפתח. קוד ה-PIN ממשיך לעבוד.",
|
||||
"passkey_failed": "מפתח הגישה לא עבד. הזינו את קוד ה-PIN.",
|
||||
"use_passkey": "שימוש במפתח גישה",
|
||||
"use_hello": "שימוש ב-Windows Hello",
|
||||
"use_touchid": "שימוש ב-Touch ID",
|
||||
"os_add": "הפעלה…",
|
||||
"os_add_title": "שימוש ב-{method}",
|
||||
"os_remove": "כיבוי",
|
||||
"os_desc": "{method} היא בדיקה של האפליקציה. אחרי אישור, האפליקציה קוראת את המפתח מהאחסון המוצפן של המחשב הזה. תוכנה שרצה תחת חשבון המשתמש שלכם יכולה לקרוא אותו שם גם בלי {method}, ולכן קוד ה-PIN מגן יותר. קוד ה-PIN ממשיך לעבוד.",
|
||||
"os_failed": "זה לא הצליח. הזינו את קוד ה-PIN.",
|
||||
"lock_title": "{app} נעולה",
|
||||
"locking": "נועל…",
|
||||
"pin_prompt": "הזינו את קוד ה-PIN כדי להמשיך.",
|
||||
"unlock": "פתיחה",
|
||||
"unlocking": "פותח…",
|
||||
"wrong_pin": "קוד ה-PIN הזה שגוי.",
|
||||
"unlock_failed": "הפתיחה לא הצליחה. נסו שוב.",
|
||||
"blocked_later": "יותר מדי ניסיונות. נסו מאוחר יותר.",
|
||||
"forgot": "שכחתם את קוד ה-PIN?",
|
||||
"forgot_title": "להסיר נתונים מקומיים ולהתנתק?",
|
||||
"forgot_body": "אי אפשר לשחזר קוד PIN שנשכח. פעולה זו מסירה את הדואר שנשמר במכשיר הזה ומנתקת את כל החשבונות, כדי שתוכלו להיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.",
|
||||
"forgot_provider": "אם התחברתם דרך דף ההתחברות של הארגון, ההתחברות נשארת פתוחה שם. ההתחברות הבאה כאן תבקש מהדף הזה התחברות חדשה, אם הוא מאפשר זאת.",
|
||||
"forgot_confirm": "הסרה והתנתקות",
|
||||
"unlock_in_main": "פתחו את הנעילה בחלון הראשי.",
|
||||
"open_main": "מעבר לחלון הראשי",
|
||||
"setup_required_title": "הארגון שלכם דורש נעילת אפליקציה",
|
||||
"setup_required_body": "בחרו קוד PIN. תצטרכו אותו כדי לפתוח את {app} אחרי הנעילה.",
|
||||
"draft_saved_notice": "מה שכתבתם נשמר בטיוטות לפני הנעילה.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}",
|
||||
"blocked_seconds": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {שנייה אחת} two {שתי שניות} other {# שניות}}.",
|
||||
"blocked_minutes": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "כניסה באמצעות הדפדפן",
|
||||
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Bezárás"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Alkalmazászár",
|
||||
"section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.",
|
||||
"enable_label": "Zárolás PIN-kóddal",
|
||||
"enable_desc": "Alapból ki van kapcsolva. A PIN-kód ezen az eszközön marad.",
|
||||
"protects_desc": "Mi ellen véd: böngészőben a PIN-kód távol tartja az alkalmazástól az eszköznél lévőket, az itt tárolt levelek pedig titkosítva maradnak. Nem állít meg egy ezen a számítógépen futó programot, egy ön által telepített böngészőbővítményt, vagy valakit, aki lemásolja a böngésző fájljait, és van ideje sok kódot kipróbálni, ezért megéri a hosszabb PIN-kód. Az asztali alkalmazásban a PIN-kód ehhez a számítógépes bejelentkezéshez is kötődik. A szerveren lévő fiókját ez nem érinti.",
|
||||
"enable_body": "Válasszon PIN-kódot. Minden zároláskor, valamint újratöltés vagy új indítás után szükség lesz rá.",
|
||||
"set_pin_title": "PIN-kód választása",
|
||||
"change_pin": "PIN-kód módosítása…",
|
||||
"change_pin_title": "A PIN-kód módosítása",
|
||||
"pin_label": "PIN-kód",
|
||||
"pin_current": "Jelenlegi PIN-kód",
|
||||
"pin_new": "Új PIN-kód",
|
||||
"pin_repeat": "PIN-kód ismét",
|
||||
"pin_hint": "Legalább 6 karakter. Betűk is jók, és a hosszabb PIN-kódot nehezebb kitalálni.",
|
||||
"pin_obvious": "Ezt a PIN-kódot túl könnyű kitalálni. Kerülje az ismétlést, a sorozatokat és a gyakori választásokat.",
|
||||
"pin_mismatch": "A két PIN-kód nem egyezik.",
|
||||
"pin_recovery": "Ha elfelejti, az egyetlen megoldás az eszközön lévő adatok eltávolítása és az újbóli bejelentkezés. A szerveren lévő levelei megmaradnak. Az ezen az eszközön küldésre váró üzenetek elvesznek.",
|
||||
"save": "Mentés",
|
||||
"cancel": "Mégse",
|
||||
"save_failed": "Ez nem sikerült. Próbálja újra.",
|
||||
"timeout_label": "Zárolás ennyi után",
|
||||
"timeout_desc": "Használat nélküli idő bármelyik ablakban vagy lapon.",
|
||||
"lock_now": "Zárolás most",
|
||||
"lock_now_desc": "Azonnal zárolja az összes ablakot és lapot.",
|
||||
"disable_title": "Kikapcsolja az alkalmazászárat?",
|
||||
"disable_body": "A kikapcsoláshoz írja be a PIN-kódját.",
|
||||
"disable_confirm": "Kikapcsolás",
|
||||
"passkey_label": "Jelkulcs",
|
||||
"passkey_desc": "Feloldás olyan jelkulccsal vagy biztonsági kulccsal, amely védeni tudja a kulcsot. Nem mindegyik tudja, és a PIN-kód továbbra is működik.",
|
||||
"passkey_add": "Jelkulcs hozzáadása…",
|
||||
"passkey_add_title": "Jelkulcs hozzáadása",
|
||||
"passkey_remove": "Eltávolítás",
|
||||
"passkey_added": "A jelkulcs hozzáadva.",
|
||||
"passkey_no_prf": "Ez a jelkulcs nem tudja védeni a kulcsot. A PIN-kód továbbra is működik.",
|
||||
"passkey_failed": "A jelkulcs nem működött. Írja be a PIN-kódját.",
|
||||
"use_passkey": "Jelkulcs használata",
|
||||
"use_hello": "Windows Hello használata",
|
||||
"use_touchid": "Touch ID használata",
|
||||
"os_add": "Bekapcsolás…",
|
||||
"os_add_title": "{method} használata",
|
||||
"os_remove": "Kikapcsolás",
|
||||
"os_desc": "A(z) {method} az alkalmazás ellenőrzése. Igen után az alkalmazás kiolvassa a kulcsot a számítógép titkosított tárolójából. Egy az Ön felhasználói fiókjában futó program enélkül is kiolvashatná onnan, ezért a PIN-kód többet véd. A PIN-kód továbbra is működik.",
|
||||
"os_failed": "Ez nem sikerült. Írja be a PIN-kódját.",
|
||||
"lock_title": "A(z) {app} zárolva van",
|
||||
"locking": "Zárolás…",
|
||||
"pin_prompt": "A folytatáshoz írja be a PIN-kódját.",
|
||||
"unlock": "Feloldás",
|
||||
"unlocking": "Feloldás…",
|
||||
"wrong_pin": "Ez a PIN-kód nem helyes.",
|
||||
"unlock_failed": "A feloldás nem sikerült. Próbálja újra.",
|
||||
"blocked_later": "Túl sok próbálkozás. Próbálja később.",
|
||||
"forgot": "Elfelejtette a PIN-kódot?",
|
||||
"forgot_title": "Eltávolítja a helyi adatokat és kijelentkezik?",
|
||||
"forgot_body": "Az elfelejtett PIN-kód nem állítható vissza. Ez eltávolítja az eszközön tárolt leveleket és minden fiókot kijelentkeztet, hogy újra be tudjon jelentkezni. A szerveren lévő levelei megmaradnak. Az ezen az eszközön küldésre váró üzenetek elvesznek.",
|
||||
"forgot_provider": "Ha a szervezete bejelentkezési oldalán keresztül lépett be, az a bejelentkezés ott nyitva marad. A következő bejelentkezés itt új bejelentkezést kér attól az oldaltól, ha az lehetővé teszi.",
|
||||
"forgot_confirm": "Eltávolítás és kijelentkezés",
|
||||
"unlock_in_main": "Oldja fel a főablakban.",
|
||||
"open_main": "Ugrás a főablakra",
|
||||
"setup_required_title": "A szervezete alkalmazászárat követel meg",
|
||||
"setup_required_body": "Válasszon PIN-kódot. A(z) {app} megnyitásához kell majd zárolás után.",
|
||||
"draft_saved_notice": "Amit írt, zárolás előtt a Piszkozatok közé mentődött.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# perc} other {# perc}}",
|
||||
"blocked_seconds": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# másodperc} other {# másodperc}} múlva.",
|
||||
"blocked_minutes": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# perc} other {# perc}} múlva."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Bejelentkezés böngészővel",
|
||||
"browser_waiting": "Fejezze be a bejelentkezést a böngészőben",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Chiudi"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Blocco dell'app",
|
||||
"section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.",
|
||||
"enable_label": "Blocca con un PIN",
|
||||
"enable_desc": "Disattivato per impostazione predefinita. Il PIN resta su questo dispositivo.",
|
||||
"protects_desc": "Da cosa protegge: in un browser, il PIN tiene fuori dall'app chi si trova davanti a questo dispositivo e mantiene cifrata la posta salvata qui. Non può fermare un programma in esecuzione su questo computer, un'estensione del browser che hai installato o chi copia i file del browser e ha il tempo di provare molti PIN, quindi conviene un PIN più lungo. Nell'app desktop, il PIN è legato anche al tuo accesso a questo computer. Il tuo account sul server non cambia.",
|
||||
"enable_body": "Scegli un PIN. Ti servirà ogni volta che l'app si blocca, e dopo un ricaricamento o un nuovo avvio.",
|
||||
"set_pin_title": "Scegli un PIN",
|
||||
"change_pin": "Cambia PIN…",
|
||||
"change_pin_title": "Cambia il PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "PIN attuale",
|
||||
"pin_new": "Nuovo PIN",
|
||||
"pin_repeat": "Ripeti il PIN",
|
||||
"pin_hint": "Almeno 6 caratteri. Vanno bene anche le lettere, e un PIN più lungo è più difficile da indovinare.",
|
||||
"pin_obvious": "Questo PIN è troppo facile da indovinare. Evita ripetizioni, sequenze e scelte comuni.",
|
||||
"pin_mismatch": "I due PIN non coincidono.",
|
||||
"pin_recovery": "Se lo dimentichi, l'unica strada è rimuovere i dati da questo dispositivo e accedere di nuovo. La tua posta sul server resta. I messaggi ancora in attesa di invio su questo dispositivo vanno persi.",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"save_failed": "Non ha funzionato. Riprova.",
|
||||
"timeout_label": "Blocca dopo",
|
||||
"timeout_desc": "Tempo senza utilizzo in qualsiasi finestra o scheda.",
|
||||
"lock_now": "Blocca ora",
|
||||
"lock_now_desc": "Blocca subito tutte le finestre e le schede.",
|
||||
"disable_title": "Disattivare il blocco?",
|
||||
"disable_body": "Inserisci il PIN per disattivarlo.",
|
||||
"disable_confirm": "Disattiva",
|
||||
"passkey_label": "Passkey",
|
||||
"passkey_desc": "Sblocca con una passkey o una chiave di sicurezza in grado di proteggere la chiave. Non tutte lo sono, e il PIN continua a funzionare.",
|
||||
"passkey_add": "Aggiungi una passkey…",
|
||||
"passkey_add_title": "Aggiungi una passkey",
|
||||
"passkey_remove": "Rimuovi",
|
||||
"passkey_added": "Passkey aggiunta.",
|
||||
"passkey_no_prf": "Questa passkey non può proteggere la chiave. Il PIN continua a funzionare.",
|
||||
"passkey_failed": "La passkey non ha funzionato. Inserisci il PIN.",
|
||||
"use_passkey": "Usa una passkey",
|
||||
"use_hello": "Usa Windows Hello",
|
||||
"use_touchid": "Usa Touch ID",
|
||||
"os_add": "Attiva…",
|
||||
"os_add_title": "Usa {method}",
|
||||
"os_remove": "Disattiva",
|
||||
"os_desc": "{method} è un controllo fatto dall'app. Dopo un sì, l'app legge la chiave dall'archivio cifrato di questo computer. Un programma avviato con il tuo account utente potrebbe leggerla lì senza {method}, quindi il PIN protegge di più. Il PIN continua a funzionare.",
|
||||
"os_failed": "Non ha funzionato. Inserisci il PIN.",
|
||||
"lock_title": "{app} è bloccata",
|
||||
"locking": "Blocco in corso…",
|
||||
"pin_prompt": "Inserisci il PIN per continuare.",
|
||||
"unlock": "Sblocca",
|
||||
"unlocking": "Sblocco in corso…",
|
||||
"wrong_pin": "Questo PIN non è corretto.",
|
||||
"unlock_failed": "Lo sblocco non ha funzionato. Riprova.",
|
||||
"blocked_later": "Troppi tentativi. Riprova più tardi.",
|
||||
"forgot": "PIN dimenticato?",
|
||||
"forgot_title": "Rimuovere i dati locali e uscire?",
|
||||
"forgot_body": "Un PIN dimenticato non si può recuperare. Questo rimuove la posta salvata su questo dispositivo e disconnette tutti gli account, così puoi accedere di nuovo. La tua posta sul server resta. I messaggi ancora in attesa di invio su questo dispositivo vanno persi.",
|
||||
"forgot_provider": "Se hai effettuato l'accesso dalla pagina della tua organizzazione, quell'accesso resta aperto lì. Il prossimo accesso qui chiederà a quella pagina un nuovo accesso, se lo consente.",
|
||||
"forgot_confirm": "Rimuovi ed esci",
|
||||
"unlock_in_main": "Sblocca nella finestra principale.",
|
||||
"open_main": "Vai alla finestra principale",
|
||||
"setup_required_title": "La tua organizzazione richiede un blocco dell'app",
|
||||
"setup_required_body": "Scegli un PIN. Ti servirà per aprire {app} dopo il blocco.",
|
||||
"draft_saved_notice": "Quello che stavi scrivendo è stato salvato nelle bozze prima del blocco.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuto} other {# minuti}}",
|
||||
"blocked_seconds": "Troppi tentativi. Riprova tra {count, plural, one {# secondo} other {# secondi}}.",
|
||||
"blocked_minutes": "Troppi tentativi. Riprova tra {count, plural, one {# minuto} other {# minuti}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Accedi con il browser",
|
||||
"browser_waiting": "Completa l'accesso nel browser",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "閉じる"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "アプリのロック",
|
||||
"section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。",
|
||||
"enable_label": "PIN でロック",
|
||||
"enable_desc": "既定ではオフです。PIN はこのデバイスにだけ保存されます。",
|
||||
"protects_desc": "保護できる範囲: ブラウザーでは、PIN によりこのデバイスの前にいる人をアプリから締め出し、ここに保存されたメールを暗号化したままにします。このコンピューターで動くプログラム、インストールしたブラウザー拡張機能、ブラウザーのファイルをコピーして多くの PIN を試す時間がある人は止められないため、長い PIN をおすすめします。デスクトップアプリでは、PIN はこのコンピューターへのログインにも結び付きます。サーバー上のアカウントには影響しません。",
|
||||
"enable_body": "PIN を選んでください。アプリがロックされるたび、また再読み込みや再起動の後に必要です。",
|
||||
"set_pin_title": "PIN を選択",
|
||||
"change_pin": "PIN を変更…",
|
||||
"change_pin_title": "PIN を変更",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "現在の PIN",
|
||||
"pin_new": "新しい PIN",
|
||||
"pin_repeat": "PIN をもう一度",
|
||||
"pin_hint": "6 文字以上。文字も使えます。長い PIN ほど推測されにくくなります。",
|
||||
"pin_obvious": "この PIN は推測されやすすぎます。繰り返し、連番、よくある組み合わせは避けてください。",
|
||||
"pin_mismatch": "2 つの PIN が一致しません。",
|
||||
"pin_recovery": "忘れた場合は、このデバイスのデータを削除してサインインし直すしかありません。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"save_failed": "うまくいきませんでした。もう一度お試しください。",
|
||||
"timeout_label": "ロックまでの時間",
|
||||
"timeout_desc": "どのウィンドウやタブでも使っていない時間です。",
|
||||
"lock_now": "今すぐロック",
|
||||
"lock_now_desc": "すべてのウィンドウとタブをすぐにロックします。",
|
||||
"disable_title": "アプリのロックをオフにしますか?",
|
||||
"disable_body": "オフにするには PIN を入力してください。",
|
||||
"disable_confirm": "オフにする",
|
||||
"passkey_label": "パスキー",
|
||||
"passkey_desc": "鍵を保護できるパスキーやセキュリティキーでロックを解除します。すべてが対応しているわけではなく、PIN は引き続き使えます。",
|
||||
"passkey_add": "パスキーを追加…",
|
||||
"passkey_add_title": "パスキーを追加",
|
||||
"passkey_remove": "削除",
|
||||
"passkey_added": "パスキーを追加しました。",
|
||||
"passkey_no_prf": "このパスキーでは鍵を保護できません。PIN は引き続き使えます。",
|
||||
"passkey_failed": "パスキーが機能しませんでした。PIN を入力してください。",
|
||||
"use_passkey": "パスキーを使う",
|
||||
"use_hello": "Windows Hello を使う",
|
||||
"use_touchid": "Touch ID を使う",
|
||||
"os_add": "オンにする…",
|
||||
"os_add_title": "{method} を使う",
|
||||
"os_remove": "オフにする",
|
||||
"os_desc": "{method} はアプリが行う確認です。確認後、アプリはこのコンピューターの暗号化ストレージから鍵を読み取ります。あなたのユーザーアカウントで動くプログラムは {method} なしでも読み取れるため、PIN のほうが強く保護します。PIN は引き続き使えます。",
|
||||
"os_failed": "うまくいきませんでした。PIN を入力してください。",
|
||||
"lock_title": "{app} はロックされています",
|
||||
"locking": "ロックしています…",
|
||||
"pin_prompt": "続けるには PIN を入力してください。",
|
||||
"unlock": "ロック解除",
|
||||
"unlocking": "ロックを解除しています…",
|
||||
"wrong_pin": "この PIN は正しくありません。",
|
||||
"unlock_failed": "ロックを解除できませんでした。もう一度お試しください。",
|
||||
"blocked_later": "試行回数が多すぎます。後でもう一度お試しください。",
|
||||
"forgot": "PIN を忘れましたか?",
|
||||
"forgot_title": "ローカルデータを削除してサインアウトしますか?",
|
||||
"forgot_body": "忘れた PIN は復元できません。このデバイスに保存されたメールを削除し、すべてのアカウントをサインアウトして、サインインし直せるようにします。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。",
|
||||
"forgot_provider": "組織のログインページからサインインした場合、そのログインはそちらで開いたままです。次回ここでサインインするときは、可能であればそのページに新しいログインを求めます。",
|
||||
"forgot_confirm": "削除してサインアウト",
|
||||
"unlock_in_main": "メインウィンドウでロックを解除してください。",
|
||||
"open_main": "メインウィンドウへ",
|
||||
"setup_required_title": "組織がアプリのロックを必須にしています",
|
||||
"setup_required_body": "PIN を選んでください。ロック後に {app} を開くときに必要です。",
|
||||
"draft_saved_notice": "書いていた内容はロックの前に下書きに保存しました。",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, other {# 分}}",
|
||||
"blocked_seconds": "試行回数が多すぎます。{count, plural, other {# 秒}}後にもう一度お試しください。",
|
||||
"blocked_minutes": "試行回数が多すぎます。{count, plural, other {# 分}}後にもう一度お試しください。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ブラウザでサインイン",
|
||||
"browser_waiting": "ブラウザでサインインを完了してください",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "닫기"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "앱 잠금",
|
||||
"section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.",
|
||||
"enable_label": "PIN으로 잠금",
|
||||
"enable_desc": "기본적으로 꺼져 있습니다. PIN은 이 기기에만 남습니다.",
|
||||
"protects_desc": "보호 범위: 브라우저에서 PIN은 이 기기 앞에 있는 사람이 앱에 들어오지 못하게 하고 여기에 저장된 메일을 암호화된 상태로 유지합니다. 이 컴퓨터에서 실행되는 프로그램, 직접 설치한 브라우저 확장 프로그램, 브라우저 파일을 복사해 많은 PIN을 시도할 시간이 있는 사람은 막을 수 없으므로 더 긴 PIN을 사용하는 것이 좋습니다. 데스크톱 앱에서는 PIN이 이 컴퓨터의 로그인에도 연결됩니다. 서버의 계정에는 영향이 없습니다.",
|
||||
"enable_body": "PIN을 선택하세요. 앱이 잠길 때마다, 그리고 다시 로드하거나 새로 시작한 후에 필요합니다.",
|
||||
"set_pin_title": "PIN 선택",
|
||||
"change_pin": "PIN 변경…",
|
||||
"change_pin_title": "PIN 변경",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "현재 PIN",
|
||||
"pin_new": "새 PIN",
|
||||
"pin_repeat": "PIN 다시 입력",
|
||||
"pin_hint": "6자 이상. 문자도 사용할 수 있으며 PIN이 길수록 추측하기 어렵습니다.",
|
||||
"pin_obvious": "이 PIN은 너무 쉽게 추측할 수 있습니다. 반복, 연속된 숫자, 흔한 조합은 피하세요.",
|
||||
"pin_mismatch": "두 PIN이 같지 않습니다.",
|
||||
"pin_recovery": "잊어버리면 이 기기의 데이터를 제거하고 다시 로그인하는 방법밖에 없습니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"save_failed": "실패했습니다. 다시 시도하세요.",
|
||||
"timeout_label": "잠금 시간",
|
||||
"timeout_desc": "창이나 탭 어디에서도 사용하지 않은 시간입니다.",
|
||||
"lock_now": "지금 잠금",
|
||||
"lock_now_desc": "모든 창과 탭을 즉시 잠급니다.",
|
||||
"disable_title": "앱 잠금을 끄시겠습니까?",
|
||||
"disable_body": "끄려면 PIN을 입력하세요.",
|
||||
"disable_confirm": "끄기",
|
||||
"passkey_label": "패스키",
|
||||
"passkey_desc": "키를 보호할 수 있는 패스키나 보안 키로 잠금을 해제합니다. 모두 가능한 것은 아니며 PIN은 계속 작동합니다.",
|
||||
"passkey_add": "패스키 추가…",
|
||||
"passkey_add_title": "패스키 추가",
|
||||
"passkey_remove": "제거",
|
||||
"passkey_added": "패스키를 추가했습니다.",
|
||||
"passkey_no_prf": "이 패스키는 키를 보호할 수 없습니다. PIN은 계속 작동합니다.",
|
||||
"passkey_failed": "패스키가 작동하지 않았습니다. PIN을 입력하세요.",
|
||||
"use_passkey": "패스키 사용",
|
||||
"use_hello": "Windows Hello 사용",
|
||||
"use_touchid": "Touch ID 사용",
|
||||
"os_add": "켜기…",
|
||||
"os_add_title": "{method} 사용",
|
||||
"os_remove": "끄기",
|
||||
"os_desc": "{method}은(는) 앱이 하는 확인입니다. 확인되면 앱이 이 컴퓨터의 암호화된 저장소에서 키를 읽습니다. 사용자 계정으로 실행되는 프로그램은 {method} 없이도 읽을 수 있으므로 PIN이 더 강하게 보호합니다. PIN은 계속 작동합니다.",
|
||||
"os_failed": "실패했습니다. PIN을 입력하세요.",
|
||||
"lock_title": "{app}이(가) 잠겨 있습니다",
|
||||
"locking": "잠그는 중…",
|
||||
"pin_prompt": "계속하려면 PIN을 입력하세요.",
|
||||
"unlock": "잠금 해제",
|
||||
"unlocking": "잠금 해제 중…",
|
||||
"wrong_pin": "PIN이 올바르지 않습니다.",
|
||||
"unlock_failed": "잠금을 해제하지 못했습니다. 다시 시도하세요.",
|
||||
"blocked_later": "시도 횟수가 너무 많습니다. 나중에 다시 시도하세요.",
|
||||
"forgot": "PIN을 잊으셨나요?",
|
||||
"forgot_title": "로컬 데이터를 제거하고 로그아웃하시겠습니까?",
|
||||
"forgot_body": "잊어버린 PIN은 복구할 수 없습니다. 이 기기에 보관된 메일을 제거하고 모든 계정을 로그아웃하여 다시 로그인할 수 있게 합니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.",
|
||||
"forgot_provider": "조직의 로그인 페이지로 로그인했다면 그 로그인은 그곳에 열린 채로 남습니다. 다음에 여기서 로그인할 때 가능하면 그 페이지에 새 로그인을 요청합니다.",
|
||||
"forgot_confirm": "제거 후 로그아웃",
|
||||
"unlock_in_main": "기본 창에서 잠금을 해제하세요.",
|
||||
"open_main": "기본 창으로 이동",
|
||||
"setup_required_title": "조직에서 앱 잠금을 요구합니다",
|
||||
"setup_required_body": "PIN을 선택하세요. 잠긴 후 {app}을(를) 열 때 필요합니다.",
|
||||
"draft_saved_notice": "작성 중이던 내용은 잠기기 전에 임시 보관함에 저장되었습니다.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, other {#분}}",
|
||||
"blocked_seconds": "시도 횟수가 너무 많습니다. {count, plural, other {#초}} 후에 다시 시도하세요.",
|
||||
"blocked_minutes": "시도 횟수가 너무 많습니다. {count, plural, other {#분}} 후에 다시 시도하세요."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "브라우저로 로그인",
|
||||
"browser_waiting": "브라우저에서 로그인을 마치세요",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Aizvērt"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Lietotnes bloķēšana",
|
||||
"section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.",
|
||||
"enable_label": "Bloķēt ar PIN kodu",
|
||||
"enable_desc": "Pēc noklusējuma izslēgts. PIN kods paliek šajā ierīcē.",
|
||||
"protects_desc": "No kā tas aizsargā: pārlūkā PIN neļauj kādam pie šīs ierīces iekļūt lietotnē, un šeit saglabātais pasts paliek šifrēts. Tas nevar apturēt programmu šajā datorā, jūsu instalētu pārlūka paplašinājumu vai kādu, kas nokopē pārlūka failus un var izmēģināt daudz PIN, tāpēc garāks PIN ir tā vērts. Darbvirsmas lietotnē PIN ir saistīts arī ar jūsu pieteikšanos šajā datorā. Jūsu kontu serverī tas neietekmē.",
|
||||
"enable_body": "Izvēlieties PIN kodu. Tas būs vajadzīgs katru reizi, kad lietotne bloķējas, un pēc pārlādes vai jaunas palaišanas.",
|
||||
"set_pin_title": "Izvēlieties PIN kodu",
|
||||
"change_pin": "Mainīt PIN kodu…",
|
||||
"change_pin_title": "Mainīt PIN kodu",
|
||||
"pin_label": "PIN kods",
|
||||
"pin_current": "Pašreizējais PIN kods",
|
||||
"pin_new": "Jauns PIN kods",
|
||||
"pin_repeat": "Atkārtojiet PIN kodu",
|
||||
"pin_hint": "Vismaz 6 rakstzīmes. Der arī burti, un garāku PIN kodu ir grūtāk uzminēt.",
|
||||
"pin_obvious": "Šo PIN ir pārāk viegli uzminēt. Izvairieties no atkārtojumiem, secībām un biežām izvēlēm.",
|
||||
"pin_mismatch": "Abi PIN kodi nesakrīt.",
|
||||
"pin_recovery": "Ja to aizmirsīsit, vienīgā iespēja ir noņemt datus šajā ierīcē un pieteikties vēlreiz. Jūsu e-pasts serverī paliek. Ziņojumi, kas šajā ierīcē vēl gaida sūtīšanu, tiek zaudēti.",
|
||||
"save": "Saglabāt",
|
||||
"cancel": "Atcelt",
|
||||
"save_failed": "Tas neizdevās. Mēģiniet vēlreiz.",
|
||||
"timeout_label": "Bloķēt pēc",
|
||||
"timeout_desc": "Laiks bez lietošanas jebkurā logā vai cilnē.",
|
||||
"lock_now": "Bloķēt tagad",
|
||||
"lock_now_desc": "Uzreiz bloķē visus logus un cilnes.",
|
||||
"disable_title": "Vai izslēgt lietotnes bloķēšanu?",
|
||||
"disable_body": "Lai to izslēgtu, ievadiet PIN kodu.",
|
||||
"disable_confirm": "Izslēgt",
|
||||
"passkey_label": "Piekļuves atslēga",
|
||||
"passkey_desc": "Atbloķējiet ar piekļuves vai drošības atslēgu, kas var aizsargāt atslēgu. Ne visas var, un PIN kods turpina darboties.",
|
||||
"passkey_add": "Pievienot piekļuves atslēgu…",
|
||||
"passkey_add_title": "Pievienot piekļuves atslēgu",
|
||||
"passkey_remove": "Noņemt",
|
||||
"passkey_added": "Piekļuves atslēga pievienota.",
|
||||
"passkey_no_prf": "Šī piekļuves atslēga nevar aizsargāt atslēgu. PIN kods turpina darboties.",
|
||||
"passkey_failed": "Piekļuves atslēga nedarbojās. Ievadiet PIN kodu.",
|
||||
"use_passkey": "Izmantot piekļuves atslēgu",
|
||||
"use_hello": "Izmantot Windows Hello",
|
||||
"use_touchid": "Izmantot Touch ID",
|
||||
"os_add": "Ieslēgt…",
|
||||
"os_add_title": "Izmantot {method}",
|
||||
"os_remove": "Izslēgt",
|
||||
"os_desc": "{method} ir lietotnes veikta pārbaude. Pēc apstiprinājuma lietotne nolasa atslēgu no šī datora šifrētās krātuves. Programma, kas darbojas jūsu lietotāja kontā, varētu to nolasīt arī bez {method}, tāpēc PIN kods aizsargā vairāk. PIN kods turpina darboties.",
|
||||
"os_failed": "Tas neizdevās. Ievadiet PIN kodu.",
|
||||
"lock_title": "{app} ir bloķēta",
|
||||
"locking": "Bloķē…",
|
||||
"pin_prompt": "Lai turpinātu, ievadiet PIN kodu.",
|
||||
"unlock": "Atbloķēt",
|
||||
"unlocking": "Atbloķē…",
|
||||
"wrong_pin": "Šis PIN kods nav pareizs.",
|
||||
"unlock_failed": "Atbloķēšana neizdevās. Mēģiniet vēlreiz.",
|
||||
"blocked_later": "Pārāk daudz mēģinājumu. Mēģiniet vēlāk.",
|
||||
"forgot": "Aizmirsāt PIN kodu?",
|
||||
"forgot_title": "Vai noņemt vietējos datus un izrakstīties?",
|
||||
"forgot_body": "Aizmirstu PIN kodu nevar atgūt. Tas noņem šajā ierīcē glabāto e-pastu un izraksta visus kontus, lai varētu pieteikties vēlreiz. Jūsu e-pasts serverī paliek. Ziņojumi, kas šajā ierīcē vēl gaida sūtīšanu, tiek zaudēti.",
|
||||
"forgot_provider": "Ja pieteicāties, izmantojot savas organizācijas pieteikšanās lapu, šī pieteikšanās tur paliek atvērta. Nākamā pieteikšanās šeit prasīs šai lapai jaunu pieteikšanos, ja tas ir atļauts.",
|
||||
"forgot_confirm": "Noņemt un izrakstīties",
|
||||
"unlock_in_main": "Atbloķējiet galvenajā logā.",
|
||||
"open_main": "Uz galveno logu",
|
||||
"setup_required_title": "Jūsu organizācija pieprasa lietotnes bloķēšanu",
|
||||
"setup_required_body": "Izvēlieties PIN kodu. Tas būs vajadzīgs, lai atvērtu {app} pēc bloķēšanas.",
|
||||
"draft_saved_notice": "Rakstītais pirms bloķēšanas tika saglabāts melnrakstos.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, zero {# minūtes} one {# minūtes} other {# minūtēm}}",
|
||||
"blocked_seconds": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# sekundēm} one {# sekundes} other {# sekundēm}}.",
|
||||
"blocked_minutes": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# minūtēm} one {# minūtes} other {# minūtēm}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Pierakstīties ar pārlūku",
|
||||
"browser_waiting": "Pabeidziet pierakstīšanos pārlūkā",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Хаах"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Аппын түгжээ",
|
||||
"section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.",
|
||||
"enable_label": "ПИН кодоор түгжих",
|
||||
"enable_desc": "Анхдагчаар унтраалттай. ПИН код энэ төхөөрөмж дээр үлдэнэ.",
|
||||
"protects_desc": "Юунаас хамгаалдаг вэ: хөтөч дээр ПИН нь энэ төхөөрөмжийн дэргэд байгаа хүнийг аппликейшнд оруулахгүй бөгөөд энд хадгалсан захидлыг шифрлэгдсэн байдлаар хадгалдаг. Энэ компьютер дээр ажиллаж буй программ, таны суулгасан хөтчийн өргөтгөл, хөтчийн файлуудыг хуулж олон ПИН турших цагтай хүнийг зогсоож чадахгүй тул урт ПИН хэрэглэх нь зүйтэй. Десктоп аппликейшнд ПИН энэ компьютерийн нэвтрэлттэй мөн холбогдоно. Сервер дээрх таны бүртгэлд нөлөөлөхгүй.",
|
||||
"enable_body": "ПИН код сонгоно уу. Апп түгжигдэх бүрт, мөн дахин ачаалсан эсвэл шинээр эхлүүлсний дараа хэрэгтэй.",
|
||||
"set_pin_title": "ПИН код сонгох",
|
||||
"change_pin": "ПИН код солих…",
|
||||
"change_pin_title": "ПИН код солих",
|
||||
"pin_label": "ПИН код",
|
||||
"pin_current": "Одоогийн ПИН код",
|
||||
"pin_new": "Шинэ ПИН код",
|
||||
"pin_repeat": "ПИН кодоо давтах",
|
||||
"pin_hint": "Дор хаяж 6 тэмдэгт. Үсэг ч болно, урт ПИН кодыг таахад хэцүү.",
|
||||
"pin_obvious": "Энэ ПИН-ийг таахад хэт амархан. Давталт, дараалал, түгээмэл сонголтоос зайлсхий.",
|
||||
"pin_mismatch": "Хоёр ПИН код ижил биш байна.",
|
||||
"pin_recovery": "Мартвал энэ төхөөрөмжийн өгөгдлийг устгаад дахин нэвтрэхээс өөр арга байхгүй. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.",
|
||||
"save": "Хадгалах",
|
||||
"cancel": "Цуцлах",
|
||||
"save_failed": "Болсонгүй. Дахин оролдоно уу.",
|
||||
"timeout_label": "Түгжих хугацаа",
|
||||
"timeout_desc": "Аль ч цонх эсвэл табад ашиглаагүй хугацаа.",
|
||||
"lock_now": "Одоо түгжих",
|
||||
"lock_now_desc": "Бүх цонх, табыг шууд түгжинэ.",
|
||||
"disable_title": "Аппын түгжээг унтраах уу?",
|
||||
"disable_body": "Унтраахын тулд ПИН кодоо оруулна уу.",
|
||||
"disable_confirm": "Унтраах",
|
||||
"passkey_label": "Нэвтрэх түлхүүр",
|
||||
"passkey_desc": "Түлхүүрийг хамгаалж чадах нэвтрэх эсвэл аюулгүйн түлхүүрээр нээнэ. Бүгд чадахгүй бөгөөд ПИН код ажилласаар байна.",
|
||||
"passkey_add": "Нэвтрэх түлхүүр нэмэх…",
|
||||
"passkey_add_title": "Нэвтрэх түлхүүр нэмэх",
|
||||
"passkey_remove": "Хасах",
|
||||
"passkey_added": "Нэвтрэх түлхүүр нэмэгдлээ.",
|
||||
"passkey_no_prf": "Энэ нэвтрэх түлхүүр түлхүүрийг хамгаалж чадахгүй. ПИН код ажилласаар байна.",
|
||||
"passkey_failed": "Нэвтрэх түлхүүр ажилласангүй. ПИН кодоо оруулна уу.",
|
||||
"use_passkey": "Нэвтрэх түлхүүр ашиглах",
|
||||
"use_hello": "Windows Hello ашиглах",
|
||||
"use_touchid": "Touch ID ашиглах",
|
||||
"os_add": "Асаах…",
|
||||
"os_add_title": "{method} ашиглах",
|
||||
"os_remove": "Унтраах",
|
||||
"os_desc": "{method} бол аппын хийдэг шалгалт юм. Зөвшөөрсний дараа апп түлхүүрийг энэ компьютерын шифрлэгдсэн санаас уншина. Таны хэрэглэгчийн бүртгэлд ажиллаж буй програм {method}-гүйгээр ч уншиж чадах тул ПИН код илүү хамгаална. ПИН код ажилласаар байна.",
|
||||
"os_failed": "Болсонгүй. ПИН кодоо оруулна уу.",
|
||||
"lock_title": "{app} түгжээтэй байна",
|
||||
"locking": "Түгжиж байна…",
|
||||
"pin_prompt": "Үргэлжлүүлэхийн тулд ПИН кодоо оруулна уу.",
|
||||
"unlock": "Түгжээ тайлах",
|
||||
"unlocking": "Түгжээ тайлж байна…",
|
||||
"wrong_pin": "Энэ ПИН код буруу байна.",
|
||||
"unlock_failed": "Түгжээ тайлж чадсангүй. Дахин оролдоно уу.",
|
||||
"blocked_later": "Хэт олон оролдлого. Дараа дахин оролдоно уу.",
|
||||
"forgot": "ПИН кодоо мартсан уу?",
|
||||
"forgot_title": "Дотоод өгөгдлийг устгаад гарах уу?",
|
||||
"forgot_body": "Мартсан ПИН кодыг сэргээх боломжгүй. Энэ нь энэ төхөөрөмжид хадгалсан имэйлийг устгаж, дахин нэвтрэх боломжтой болгохын тулд бүх бүртгэлээс гаргана. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.",
|
||||
"forgot_provider": "Хэрэв та байгууллагынхаа нэвтрэх хуудсаар нэвтэрсэн бол тэр нэвтрэлт тэнд нээлттэй үлдэнэ. Дараагийн нэвтрэлт боломжтой бол тэр хуудаснаас шинэ нэвтрэлт хүснэ.",
|
||||
"forgot_confirm": "Устгаад гарах",
|
||||
"unlock_in_main": "Үндсэн цонхонд түгжээг тайлна уу.",
|
||||
"open_main": "Үндсэн цонх руу очих",
|
||||
"setup_required_title": "Танай байгууллага аппын түгжээ шаардаж байна",
|
||||
"setup_required_body": "ПИН код сонгоно уу. Түгжигдсэний дараа {app}-ыг нээхэд хэрэгтэй.",
|
||||
"draft_saved_notice": "Бичиж байсан зүйлсийг түгжихээс өмнө ноорогт хадгалсан.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# минут} other {# минут}}",
|
||||
"blocked_seconds": "Хэт олон оролдлого. {count, plural, one {# секундын} other {# секундын}} дараа дахин оролдоно уу.",
|
||||
"blocked_minutes": "Хэт олон оролдлого. {count, plural, one {# минутын} other {# минутын}} дараа дахин оролдоно уу."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Хөтчөөр нэвтрэх",
|
||||
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Lukk"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Applås",
|
||||
"section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.",
|
||||
"enable_label": "Lås med en PIN-kode",
|
||||
"enable_desc": "Av som standard. PIN-koden blir på denne enheten.",
|
||||
"protects_desc": "Hva dette beskytter mot: I en nettleser holder PIN-koden andre ved denne enheten ute av appen og holder e-posten som er lagret her kryptert. Den kan ikke stoppe et program på denne datamaskinen, en nettleserutvidelse du har installert, eller noen som kopierer nettleserens filer og har tid til å prøve mange koder, så en lengre PIN-kode er verdt det. I skrivebordsappen er PIN-koden også knyttet til påloggingen din på denne datamaskinen. Kontoen din på serveren påvirkes ikke.",
|
||||
"enable_body": "Velg en PIN-kode. Du trenger den hver gang appen låses, og etter en ny lasting eller ny start.",
|
||||
"set_pin_title": "Velg en PIN-kode",
|
||||
"change_pin": "Endre PIN-kode…",
|
||||
"change_pin_title": "Endre PIN-koden",
|
||||
"pin_label": "PIN-kode",
|
||||
"pin_current": "Nåværende PIN-kode",
|
||||
"pin_new": "Ny PIN-kode",
|
||||
"pin_repeat": "Gjenta PIN-koden",
|
||||
"pin_hint": "Minst 6 tegn. Bokstaver går også, og en lengre PIN-kode er vanskeligere å gjette.",
|
||||
"pin_obvious": "Den PIN-koden er for lett å gjette. Unngå gjentakelser, rekkefølger og vanlige valg.",
|
||||
"pin_mismatch": "De to PIN-kodene er ikke like.",
|
||||
"pin_recovery": "Hvis du glemmer den, er eneste utvei å fjerne dataene på denne enheten og logge på igjen. E-posten din på serveren blir værende. Meldinger som fortsatt venter på sending fra denne enheten, går tapt.",
|
||||
"save": "Lagre",
|
||||
"cancel": "Avbryt",
|
||||
"save_failed": "Det virket ikke. Prøv igjen.",
|
||||
"timeout_label": "Lås etter",
|
||||
"timeout_desc": "Tid uten bruk i et vindu eller en fane.",
|
||||
"lock_now": "Lås nå",
|
||||
"lock_now_desc": "Låser alle vinduer og faner med en gang.",
|
||||
"disable_title": "Slå av applåsen?",
|
||||
"disable_body": "Skriv inn PIN-koden for å slå den av.",
|
||||
"disable_confirm": "Slå av",
|
||||
"passkey_label": "Tilgangsnøkkel",
|
||||
"passkey_desc": "Lås opp med en tilgangsnøkkel eller sikkerhetsnøkkel som kan beskytte nøkkelen. Ikke alle kan det, og PIN-koden virker fortsatt.",
|
||||
"passkey_add": "Legg til en tilgangsnøkkel…",
|
||||
"passkey_add_title": "Legg til en tilgangsnøkkel",
|
||||
"passkey_remove": "Fjern",
|
||||
"passkey_added": "Tilgangsnøkkelen er lagt til.",
|
||||
"passkey_no_prf": "Denne tilgangsnøkkelen kan ikke beskytte nøkkelen. PIN-koden virker fortsatt.",
|
||||
"passkey_failed": "Tilgangsnøkkelen virket ikke. Skriv inn PIN-koden.",
|
||||
"use_passkey": "Bruk en tilgangsnøkkel",
|
||||
"use_hello": "Bruk Windows Hello",
|
||||
"use_touchid": "Bruk Touch ID",
|
||||
"os_add": "Slå på…",
|
||||
"os_add_title": "Bruk {method}",
|
||||
"os_remove": "Slå av",
|
||||
"os_desc": "{method} er en kontroll appen gjør. Etter et ja leser appen nøkkelen fra datamaskinens krypterte lager. Et program under brukerkontoen din kan lese den der uten {method}, så PIN-koden beskytter mer. PIN-koden virker fortsatt.",
|
||||
"os_failed": "Det virket ikke. Skriv inn PIN-koden.",
|
||||
"lock_title": "{app} er låst",
|
||||
"locking": "Låser…",
|
||||
"pin_prompt": "Skriv inn PIN-koden for å fortsette.",
|
||||
"unlock": "Lås opp",
|
||||
"unlocking": "Låser opp…",
|
||||
"wrong_pin": "Den PIN-koden er ikke riktig.",
|
||||
"unlock_failed": "Opplåsing virket ikke. Prøv igjen.",
|
||||
"blocked_later": "For mange forsøk. Prøv igjen senere.",
|
||||
"forgot": "Glemt PIN-koden?",
|
||||
"forgot_title": "Fjerne lokale data og logge ut?",
|
||||
"forgot_body": "En glemt PIN-kode kan ikke gjenopprettes. Dette fjerner e-post lagret på denne enheten og logger ut alle kontoer, slik at du kan logge på igjen. E-posten din på serveren blir værende. Meldinger som fortsatt venter på sending fra denne enheten, går tapt.",
|
||||
"forgot_provider": "Hvis du logget på via organisasjonens påloggingsside, forblir den påloggingen åpen der. Neste pålogging her ber den siden om en ny pålogging, der det er mulig.",
|
||||
"forgot_confirm": "Fjern og logg ut",
|
||||
"unlock_in_main": "Lås opp i hovedvinduet.",
|
||||
"open_main": "Gå til hovedvinduet",
|
||||
"setup_required_title": "Organisasjonen din krever en applås",
|
||||
"setup_required_body": "Velg en PIN-kode. Du trenger den for å åpne {app} når den er låst.",
|
||||
"draft_saved_notice": "Det du skrev, ble lagret i Utkast før appen ble låst.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minutt} other {# minutter}}",
|
||||
"blocked_seconds": "For mange forsøk. Prøv igjen om {count, plural, one {# sekund} other {# sekunder}}.",
|
||||
"blocked_minutes": "For mange forsøk. Prøv igjen om {count, plural, one {# minutt} other {# minutter}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Logg på med nettleseren",
|
||||
"browser_waiting": "Fullfør påloggingen i nettleseren",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Sluit"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "App-vergrendeling",
|
||||
"section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.",
|
||||
"enable_label": "Vergrendelen met een pincode",
|
||||
"enable_desc": "Standaard uit. De pincode blijft op dit apparaat.",
|
||||
"protects_desc": "Waartegen dit beschermt: in een browser houdt de pincode iemand bij dit apparaat buiten de app en blijft de e-mail die hier is opgeslagen versleuteld. Hij kan geen programma op deze computer tegenhouden, geen browserextensie die je hebt geïnstalleerd en niemand die de bestanden van de browser kopieert en tijd heeft om veel pincodes te proberen, dus een langere pincode is de moeite waard. In de desktop-app is de pincode ook gekoppeld aan je aanmelding op deze computer. Je account op de server verandert niet.",
|
||||
"enable_body": "Kies een pincode. Die heeft u nodig telkens als de app vergrendelt, en na herladen of opnieuw starten.",
|
||||
"set_pin_title": "Kies een pincode",
|
||||
"change_pin": "Pincode wijzigen…",
|
||||
"change_pin_title": "De pincode wijzigen",
|
||||
"pin_label": "Pincode",
|
||||
"pin_current": "Huidige pincode",
|
||||
"pin_new": "Nieuwe pincode",
|
||||
"pin_repeat": "Herhaal de pincode",
|
||||
"pin_hint": "Minstens 6 tekens. Letters mogen ook, en een langere pincode is moeilijker te raden.",
|
||||
"pin_obvious": "Deze pincode is te makkelijk te raden. Vermijd herhalingen, reeksen en veelgebruikte keuzes.",
|
||||
"pin_mismatch": "De twee pincodes zijn niet gelijk.",
|
||||
"pin_recovery": "Als u hem vergeet, kunt u alleen de gegevens op dit apparaat verwijderen en opnieuw inloggen. Uw mail op de server blijft. Berichten die op dit apparaat nog op verzending wachten, gaan verloren.",
|
||||
"save": "Opslaan",
|
||||
"cancel": "Annuleren",
|
||||
"save_failed": "Dat is niet gelukt. Probeer het opnieuw.",
|
||||
"timeout_label": "Vergrendelen na",
|
||||
"timeout_desc": "Tijd zonder gebruik in een venster of tabblad.",
|
||||
"lock_now": "Nu vergrendelen",
|
||||
"lock_now_desc": "Vergrendelt alle vensters en tabbladen meteen.",
|
||||
"disable_title": "App-vergrendeling uitzetten?",
|
||||
"disable_body": "Voer uw pincode in om die uit te zetten.",
|
||||
"disable_confirm": "Uitzetten",
|
||||
"passkey_label": "Passkey",
|
||||
"passkey_desc": "Ontgrendel met een passkey of beveiligingssleutel die de sleutel kan beschermen. Dat kunnen ze niet allemaal, en de pincode blijft werken.",
|
||||
"passkey_add": "Passkey toevoegen…",
|
||||
"passkey_add_title": "Passkey toevoegen",
|
||||
"passkey_remove": "Verwijderen",
|
||||
"passkey_added": "Passkey toegevoegd.",
|
||||
"passkey_no_prf": "Deze passkey kan de sleutel niet beschermen. Uw pincode blijft werken.",
|
||||
"passkey_failed": "De passkey werkte niet. Voer uw pincode in.",
|
||||
"use_passkey": "Passkey gebruiken",
|
||||
"use_hello": "Windows Hello gebruiken",
|
||||
"use_touchid": "Touch ID gebruiken",
|
||||
"os_add": "Aanzetten…",
|
||||
"os_add_title": "{method} gebruiken",
|
||||
"os_remove": "Uitzetten",
|
||||
"os_desc": "{method} is een controle door de app. Na een ja leest de app de sleutel uit de versleutelde opslag van deze computer. Een programma onder uw gebruikersaccount kan hem daar ook zonder {method} lezen, dus de pincode beschermt meer. De pincode blijft werken.",
|
||||
"os_failed": "Dat is niet gelukt. Voer uw pincode in.",
|
||||
"lock_title": "{app} is vergrendeld",
|
||||
"locking": "Vergrendelen…",
|
||||
"pin_prompt": "Voer uw pincode in om verder te gaan.",
|
||||
"unlock": "Ontgrendelen",
|
||||
"unlocking": "Ontgrendelen…",
|
||||
"wrong_pin": "Die pincode klopt niet.",
|
||||
"unlock_failed": "Ontgrendelen is niet gelukt. Probeer het opnieuw.",
|
||||
"blocked_later": "Te veel pogingen. Probeer het later opnieuw.",
|
||||
"forgot": "Pincode vergeten?",
|
||||
"forgot_title": "Lokale gegevens verwijderen en uitloggen?",
|
||||
"forgot_body": "Een vergeten pincode kan niet worden hersteld. Dit verwijdert de mail op dit apparaat en logt alle accounts uit, zodat u opnieuw kunt inloggen. Uw mail op de server blijft. Berichten die op dit apparaat nog op verzending wachten, gaan verloren.",
|
||||
"forgot_provider": "Als je je hebt aangemeld via de aanmeldpagina van je organisatie, blijft die aanmelding daar open. De volgende aanmelding hier vraagt die pagina om een nieuwe aanmelding, als dat kan.",
|
||||
"forgot_confirm": "Verwijderen en uitloggen",
|
||||
"unlock_in_main": "Ontgrendel in het hoofdvenster.",
|
||||
"open_main": "Naar het hoofdvenster",
|
||||
"setup_required_title": "Uw organisatie vereist een app-vergrendeling",
|
||||
"setup_required_body": "Kies een pincode. Die heeft u nodig om {app} na het vergrendelen te openen.",
|
||||
"draft_saved_notice": "Wat u aan het schrijven was, is voor het vergrendelen opgeslagen in Concepten.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuut} other {# minuten}}",
|
||||
"blocked_seconds": "Te veel pogingen. Probeer het over {count, plural, one {# seconde} other {# seconden}} opnieuw.",
|
||||
"blocked_minutes": "Te veel pogingen. Probeer het over {count, plural, one {# minuut} other {# minuten}} opnieuw."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Inloggen met je browser",
|
||||
"browser_waiting": "Rond het inloggen af in je browser",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Zamknij"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Blokada aplikacji",
|
||||
"section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.",
|
||||
"enable_label": "Blokuj kodem PIN",
|
||||
"enable_desc": "Domyślnie wyłączone. PIN pozostaje na tym urządzeniu.",
|
||||
"protects_desc": "Przed czym to chroni: w przeglądarce PIN nie wpuszcza do aplikacji osoby przy tym urządzeniu, a zapisana tu poczta pozostaje zaszyfrowana. Nie zatrzyma programu działającego na tym komputerze, zainstalowanego przez Ciebie rozszerzenia przeglądarki ani kogoś, kto skopiuje pliki przeglądarki i ma czas na wypróbowanie wielu kodów, więc warto wybrać dłuższy PIN. W aplikacji komputerowej PIN jest też powiązany z Twoim logowaniem na tym komputerze. Nie ma to wpływu na Twoje konto na serwerze.",
|
||||
"enable_body": "Wybierz PIN. Będzie potrzebny za każdym razem, gdy aplikacja się zablokuje, oraz po przeładowaniu lub ponownym uruchomieniu.",
|
||||
"set_pin_title": "Wybierz PIN",
|
||||
"change_pin": "Zmień PIN…",
|
||||
"change_pin_title": "Zmień PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "Obecny PIN",
|
||||
"pin_new": "Nowy PIN",
|
||||
"pin_repeat": "Powtórz PIN",
|
||||
"pin_hint": "Co najmniej 6 znaków. Można też użyć liter, a dłuższy PIN trudniej odgadnąć.",
|
||||
"pin_obvious": "Ten PIN jest zbyt łatwy do odgadnięcia. Unikaj powtórzeń, ciągów i popularnych wyborów.",
|
||||
"pin_mismatch": "Te dwa kody PIN nie są takie same.",
|
||||
"pin_recovery": "Jeśli go zapomnisz, jedynym wyjściem jest usunięcie danych z tego urządzenia i ponowne zalogowanie. Twoja poczta na serwerze zostaje. Wiadomości czekające na wysłanie na tym urządzeniu przepadną.",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"save_failed": "To się nie udało. Spróbuj ponownie.",
|
||||
"timeout_label": "Blokuj po",
|
||||
"timeout_desc": "Czas bez użycia w dowolnym oknie lub karcie.",
|
||||
"lock_now": "Zablokuj teraz",
|
||||
"lock_now_desc": "Blokuje od razu wszystkie okna i karty.",
|
||||
"disable_title": "Wyłączyć blokadę aplikacji?",
|
||||
"disable_body": "Wpisz PIN, aby ją wyłączyć.",
|
||||
"disable_confirm": "Wyłącz",
|
||||
"passkey_label": "Klucz dostępu",
|
||||
"passkey_desc": "Odblokuj kluczem dostępu lub kluczem bezpieczeństwa, który potrafi chronić klucz. Nie każdy to potrafi, a PIN nadal działa.",
|
||||
"passkey_add": "Dodaj klucz dostępu…",
|
||||
"passkey_add_title": "Dodaj klucz dostępu",
|
||||
"passkey_remove": "Usuń",
|
||||
"passkey_added": "Dodano klucz dostępu.",
|
||||
"passkey_no_prf": "Ten klucz dostępu nie może chronić klucza. PIN nadal działa.",
|
||||
"passkey_failed": "Klucz dostępu nie zadziałał. Wpisz PIN.",
|
||||
"use_passkey": "Użyj klucza dostępu",
|
||||
"use_hello": "Użyj Windows Hello",
|
||||
"use_touchid": "Użyj Touch ID",
|
||||
"os_add": "Włącz…",
|
||||
"os_add_title": "Użyj: {method}",
|
||||
"os_remove": "Wyłącz",
|
||||
"os_desc": "{method} to sprawdzenie wykonywane przez aplikację. Po potwierdzeniu aplikacja odczytuje klucz z zaszyfrowanego magazynu tego komputera. Program działający na Twoim koncie użytkownika mógłby go tam odczytać także bez tego, więc PIN chroni lepiej. PIN nadal działa.",
|
||||
"os_failed": "To się nie udało. Wpisz PIN.",
|
||||
"lock_title": "{app} jest zablokowana",
|
||||
"locking": "Blokowanie…",
|
||||
"pin_prompt": "Wpisz PIN, aby kontynuować.",
|
||||
"unlock": "Odblokuj",
|
||||
"unlocking": "Odblokowywanie…",
|
||||
"wrong_pin": "Ten PIN jest nieprawidłowy.",
|
||||
"unlock_failed": "Odblokowanie się nie udało. Spróbuj ponownie.",
|
||||
"blocked_later": "Zbyt wiele prób. Spróbuj później.",
|
||||
"forgot": "Nie pamiętasz kodu PIN?",
|
||||
"forgot_title": "Usunąć dane lokalne i wylogować?",
|
||||
"forgot_body": "Zapomnianego kodu PIN nie da się odzyskać. To usunie pocztę zapisaną na tym urządzeniu i wyloguje wszystkie konta, aby można było zalogować się ponownie. Twoja poczta na serwerze zostaje. Wiadomości czekające na wysłanie na tym urządzeniu przepadną.",
|
||||
"forgot_provider": "Jeśli logujesz się przez stronę logowania swojej organizacji, to logowanie pozostaje tam otwarte. Następne logowanie tutaj poprosi tę stronę o nowe logowanie, jeśli to możliwe.",
|
||||
"forgot_confirm": "Usuń i wyloguj",
|
||||
"unlock_in_main": "Odblokuj w oknie głównym.",
|
||||
"open_main": "Przejdź do okna głównego",
|
||||
"setup_required_title": "Twoja organizacja wymaga blokady aplikacji",
|
||||
"setup_required_body": "Wybierz PIN. Będzie potrzebny do otwarcia {app} po zablokowaniu.",
|
||||
"draft_saved_notice": "To, co pisano, zapisano w wersjach roboczych przed zablokowaniem.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuta} few {# minuty} many {# minut} other {# minuty}}",
|
||||
"blocked_seconds": "Zbyt wiele prób. Spróbuj za {count, plural, one {# sekundę} few {# sekundy} many {# sekund} other {# sekundy}}.",
|
||||
"blocked_minutes": "Zbyt wiele prób. Spróbuj za {count, plural, one {# minutę} few {# minuty} many {# minut} other {# minuty}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Zaloguj się w przeglądarce",
|
||||
"browser_waiting": "Dokończ logowanie w przeglądarce",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Fechar"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Bloqueio da app",
|
||||
"section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.",
|
||||
"enable_label": "Bloquear com um PIN",
|
||||
"enable_desc": "Desativado por predefinição. O PIN fica neste dispositivo.",
|
||||
"protects_desc": "Contra o que isto protege: num navegador, o PIN impede que alguém junto a este dispositivo entre na aplicação e mantém encriptado o correio guardado aqui. Não consegue travar um programa em execução neste computador, uma extensão do navegador que tenha instalado nem alguém que copie os ficheiros do navegador e tenha tempo para experimentar muitos PIN, por isso vale a pena um PIN mais longo. Na aplicação de computador, o PIN também fica ligado ao seu início de sessão neste computador. A sua conta no servidor não é afetada.",
|
||||
"enable_body": "Escolha um PIN. Vai precisar dele sempre que a app bloquear, e depois de recarregar ou iniciar de novo.",
|
||||
"set_pin_title": "Escolher um PIN",
|
||||
"change_pin": "Alterar PIN…",
|
||||
"change_pin_title": "Alterar o PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "PIN atual",
|
||||
"pin_new": "Novo PIN",
|
||||
"pin_repeat": "Repetir o PIN",
|
||||
"pin_hint": "Pelo menos 6 caracteres. Também pode usar letras, e um PIN mais longo é mais difícil de adivinhar.",
|
||||
"pin_obvious": "Esse PIN é muito fácil de adivinhar. Evite repetições, sequências e escolhas comuns.",
|
||||
"pin_mismatch": "Os dois PIN não são iguais.",
|
||||
"pin_recovery": "Se o esquecer, a única saída é remover os dados deste dispositivo e voltar a iniciar sessão. O seu correio no servidor mantém-se. As mensagens ainda à espera de envio neste dispositivo perdem-se.",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"save_failed": "Não funcionou. Tente novamente.",
|
||||
"timeout_label": "Bloquear após",
|
||||
"timeout_desc": "Tempo sem uso em qualquer janela ou separador.",
|
||||
"lock_now": "Bloquear agora",
|
||||
"lock_now_desc": "Bloqueia todas as janelas e separadores de imediato.",
|
||||
"disable_title": "Desativar o bloqueio?",
|
||||
"disable_body": "Introduza o seu PIN para o desativar.",
|
||||
"disable_confirm": "Desativar",
|
||||
"passkey_label": "Chave de acesso",
|
||||
"passkey_desc": "Desbloqueie com uma chave de acesso ou de segurança capaz de proteger a chave. Nem todas conseguem, e o PIN continua a funcionar.",
|
||||
"passkey_add": "Adicionar uma chave de acesso…",
|
||||
"passkey_add_title": "Adicionar uma chave de acesso",
|
||||
"passkey_remove": "Remover",
|
||||
"passkey_added": "Chave de acesso adicionada.",
|
||||
"passkey_no_prf": "Esta chave de acesso não consegue proteger a chave. O seu PIN continua a funcionar.",
|
||||
"passkey_failed": "A chave de acesso não funcionou. Introduza o seu PIN.",
|
||||
"use_passkey": "Usar uma chave de acesso",
|
||||
"use_hello": "Usar o Windows Hello",
|
||||
"use_touchid": "Usar o Touch ID",
|
||||
"os_add": "Ativar…",
|
||||
"os_add_title": "Usar {method}",
|
||||
"os_remove": "Desativar",
|
||||
"os_desc": "{method} é uma verificação feita pela app. Depois de um sim, a app lê a chave do armazenamento cifrado deste computador. Um programa a correr na sua conta de utilizador poderia lê-la ali sem {method}, por isso o PIN protege mais. O PIN continua a funcionar.",
|
||||
"os_failed": "Não funcionou. Introduza o seu PIN.",
|
||||
"lock_title": "{app} está bloqueada",
|
||||
"locking": "A bloquear…",
|
||||
"pin_prompt": "Introduza o seu PIN para continuar.",
|
||||
"unlock": "Desbloquear",
|
||||
"unlocking": "A desbloquear…",
|
||||
"wrong_pin": "Esse PIN não está certo.",
|
||||
"unlock_failed": "O desbloqueio não funcionou. Tente novamente.",
|
||||
"blocked_later": "Demasiadas tentativas. Tente mais tarde.",
|
||||
"forgot": "Esqueceu-se do PIN?",
|
||||
"forgot_title": "Remover os dados locais e terminar sessão?",
|
||||
"forgot_body": "Um PIN esquecido não pode ser recuperado. Isto remove o correio guardado neste dispositivo e termina a sessão de todas as contas, para que possa entrar de novo. O seu correio no servidor mantém-se. As mensagens ainda à espera de envio neste dispositivo perdem-se.",
|
||||
"forgot_provider": "Se iniciou sessão pela página da sua organização, essa sessão continua aberta lá. O próximo início de sessão aqui pedirá a essa página uma nova sessão, se ela o permitir.",
|
||||
"forgot_confirm": "Remover e terminar sessão",
|
||||
"unlock_in_main": "Desbloqueie na janela principal.",
|
||||
"open_main": "Ir para a janela principal",
|
||||
"setup_required_title": "A sua organização exige um bloqueio da app",
|
||||
"setup_required_body": "Escolha um PIN. Vai precisar dele para abrir {app} depois de bloquear.",
|
||||
"draft_saved_notice": "O que estava a escrever foi guardado nos Rascunhos antes do bloqueio.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minuto} other {# minutos}}",
|
||||
"blocked_seconds": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# segundo} other {# segundos}}.",
|
||||
"blocked_minutes": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# minuto} other {# minutos}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Entrar com o navegador",
|
||||
"browser_waiting": "Termine de entrar no navegador",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Închidere"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Blocarea aplicației",
|
||||
"section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.",
|
||||
"enable_label": "Blochează cu un PIN",
|
||||
"enable_desc": "Dezactivat implicit. PIN-ul rămâne pe acest dispozitiv.",
|
||||
"protects_desc": "De ce protejează: într-un browser, PIN-ul ține în afara aplicației pe cineva aflat la acest dispozitiv și păstrează criptate e-mailurile stocate aici. Nu poate opri un program care rulează pe acest computer, o extensie de browser instalată de dvs. sau pe cineva care copiază fișierele browserului și are timp să încerce multe coduri, așa că merită un PIN mai lung. În aplicația desktop, PIN-ul este legat și de autentificarea dvs. pe acest computer. Contul de pe server nu este afectat.",
|
||||
"enable_body": "Alegeți un PIN. Veți avea nevoie de el de fiecare dată când aplicația se blochează, și după o reîncărcare sau o repornire.",
|
||||
"set_pin_title": "Alegeți un PIN",
|
||||
"change_pin": "Schimbați PIN-ul…",
|
||||
"change_pin_title": "Schimbați PIN-ul",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "PIN-ul actual",
|
||||
"pin_new": "PIN nou",
|
||||
"pin_repeat": "Repetați PIN-ul",
|
||||
"pin_hint": "Cel puțin 6 caractere. Merg și literele, iar un PIN mai lung e mai greu de ghicit.",
|
||||
"pin_obvious": "Acest PIN este prea ușor de ghicit. Evitați repetițiile, secvențele și alegerile obișnuite.",
|
||||
"pin_mismatch": "Cele două PIN-uri nu sunt la fel.",
|
||||
"pin_recovery": "Dacă îl uitați, singura soluție este să eliminați datele de pe acest dispozitiv și să vă conectați din nou. E-mailurile de pe server rămân. Mesajele care încă așteaptă trimiterea pe acest dispozitiv se pierd.",
|
||||
"save": "Salvați",
|
||||
"cancel": "Anulați",
|
||||
"save_failed": "Nu a mers. Încercați din nou.",
|
||||
"timeout_label": "Blochează după",
|
||||
"timeout_desc": "Timp fără utilizare în orice fereastră sau filă.",
|
||||
"lock_now": "Blochează acum",
|
||||
"lock_now_desc": "Blochează imediat toate ferestrele și filele.",
|
||||
"disable_title": "Dezactivați blocarea?",
|
||||
"disable_body": "Introduceți PIN-ul pentru a o dezactiva.",
|
||||
"disable_confirm": "Dezactivați",
|
||||
"passkey_label": "Cheie de acces",
|
||||
"passkey_desc": "Deblocați cu o cheie de acces sau de securitate care poate proteja cheia. Nu toate pot, iar PIN-ul funcționează în continuare.",
|
||||
"passkey_add": "Adăugați o cheie de acces…",
|
||||
"passkey_add_title": "Adăugați o cheie de acces",
|
||||
"passkey_remove": "Eliminați",
|
||||
"passkey_added": "Cheia de acces a fost adăugată.",
|
||||
"passkey_no_prf": "Această cheie de acces nu poate proteja cheia. PIN-ul funcționează în continuare.",
|
||||
"passkey_failed": "Cheia de acces nu a mers. Introduceți PIN-ul.",
|
||||
"use_passkey": "Folosiți o cheie de acces",
|
||||
"use_hello": "Folosiți Windows Hello",
|
||||
"use_touchid": "Folosiți Touch ID",
|
||||
"os_add": "Activați…",
|
||||
"os_add_title": "Folosiți {method}",
|
||||
"os_remove": "Dezactivați",
|
||||
"os_desc": "{method} este o verificare făcută de aplicație. După un da, aplicația citește cheia din spațiul criptat al acestui computer. Un program care rulează sub contul dvs. ar putea-o citi acolo fără {method}, deci PIN-ul protejează mai mult. PIN-ul funcționează în continuare.",
|
||||
"os_failed": "Nu a mers. Introduceți PIN-ul.",
|
||||
"lock_title": "{app} este blocată",
|
||||
"locking": "Se blochează…",
|
||||
"pin_prompt": "Introduceți PIN-ul pentru a continua.",
|
||||
"unlock": "Deblocați",
|
||||
"unlocking": "Se deblochează…",
|
||||
"wrong_pin": "Acest PIN nu este corect.",
|
||||
"unlock_failed": "Deblocarea nu a mers. Încercați din nou.",
|
||||
"blocked_later": "Prea multe încercări. Încercați mai târziu.",
|
||||
"forgot": "Ați uitat PIN-ul?",
|
||||
"forgot_title": "Eliminați datele locale și vă deconectați?",
|
||||
"forgot_body": "Un PIN uitat nu poate fi recuperat. Astfel se elimină e-mailurile păstrate pe acest dispozitiv și toate conturile sunt deconectate, ca să vă puteți conecta din nou. E-mailurile de pe server rămân. Mesajele care încă așteaptă trimiterea pe acest dispozitiv se pierd.",
|
||||
"forgot_provider": "Dacă v-ați autentificat prin pagina organizației, acea autentificare rămâne deschisă acolo. Următoarea autentificare de aici va cere acelei pagini o nouă autentificare, dacă permite.",
|
||||
"forgot_confirm": "Eliminați și deconectați",
|
||||
"unlock_in_main": "Deblocați în fereastra principală.",
|
||||
"open_main": "Mergeți la fereastra principală",
|
||||
"setup_required_title": "Organizația dvs. cere o blocare a aplicației",
|
||||
"setup_required_body": "Alegeți un PIN. Veți avea nevoie de el pentru a deschide {app} după blocare.",
|
||||
"draft_saved_notice": "Ce scriați a fost salvat în Ciorne înainte de blocare.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minut} few {# minute} other {# de minute}}",
|
||||
"blocked_seconds": "Prea multe încercări. Încercați din nou peste {count, plural, one {# secundă} few {# secunde} other {# de secunde}}.",
|
||||
"blocked_minutes": "Prea multe încercări. Încercați din nou peste {count, plural, one {# minut} few {# minute} other {# de minute}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Conectează-te cu browserul",
|
||||
"browser_waiting": "Finalizează conectarea în browser",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Закрыть"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Блокировка приложения",
|
||||
"section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.",
|
||||
"enable_label": "Блокировать PIN-кодом",
|
||||
"enable_desc": "По умолчанию выключено. PIN-код остаётся на этом устройстве.",
|
||||
"protects_desc": "От чего это защищает: в браузере PIN-код не пускает в приложение того, кто находится у этого устройства, и хранит сохранённую здесь почту в зашифрованном виде. Он не остановит программу на этом компьютере, установленное вами расширение браузера или того, кто скопирует файлы браузера и сможет перебирать много кодов, поэтому стоит выбрать PIN-код подлиннее. В настольном приложении PIN-код также привязан к вашему входу на этом компьютере. Ваша учётная запись на сервере не затрагивается.",
|
||||
"enable_body": "Выберите PIN-код. Он понадобится при каждой блокировке приложения, а также после перезагрузки или нового запуска.",
|
||||
"set_pin_title": "Выберите PIN-код",
|
||||
"change_pin": "Изменить PIN-код…",
|
||||
"change_pin_title": "Изменить PIN-код",
|
||||
"pin_label": "PIN-код",
|
||||
"pin_current": "Текущий PIN-код",
|
||||
"pin_new": "Новый PIN-код",
|
||||
"pin_repeat": "Повторите PIN-код",
|
||||
"pin_hint": "Не менее 6 символов. Можно использовать буквы, а длинный PIN-код труднее угадать.",
|
||||
"pin_obvious": "Этот PIN-код слишком легко угадать. Избегайте повторов, последовательностей и распространённых вариантов.",
|
||||
"pin_mismatch": "PIN-коды не совпадают.",
|
||||
"pin_recovery": "Если вы его забудете, останется только удалить данные на этом устройстве и войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"save_failed": "Не получилось. Попробуйте ещё раз.",
|
||||
"timeout_label": "Блокировать через",
|
||||
"timeout_desc": "Время бездействия в любом окне или вкладке.",
|
||||
"lock_now": "Заблокировать сейчас",
|
||||
"lock_now_desc": "Сразу блокирует все окна и вкладки.",
|
||||
"disable_title": "Выключить блокировку приложения?",
|
||||
"disable_body": "Введите PIN-код, чтобы выключить её.",
|
||||
"disable_confirm": "Выключить",
|
||||
"passkey_label": "Ключ доступа",
|
||||
"passkey_desc": "Разблокировка ключом доступа или ключом безопасности, способным защитить ключ. Не все на это способны, а PIN-код продолжает работать.",
|
||||
"passkey_add": "Добавить ключ доступа…",
|
||||
"passkey_add_title": "Добавить ключ доступа",
|
||||
"passkey_remove": "Удалить",
|
||||
"passkey_added": "Ключ доступа добавлен.",
|
||||
"passkey_no_prf": "Этот ключ доступа не может защитить ключ. PIN-код продолжает работать.",
|
||||
"passkey_failed": "Ключ доступа не сработал. Введите PIN-код.",
|
||||
"use_passkey": "Использовать ключ доступа",
|
||||
"use_hello": "Использовать Windows Hello",
|
||||
"use_touchid": "Использовать Touch ID",
|
||||
"os_add": "Включить…",
|
||||
"os_add_title": "Использовать {method}",
|
||||
"os_remove": "Выключить",
|
||||
"os_desc": "{method} — это проверка, которую выполняет приложение. После подтверждения приложение читает ключ из зашифрованного хранилища этого компьютера. Программа, запущенная под вашей учётной записью, могла бы прочитать его там и без {method}, поэтому PIN-код защищает лучше. PIN-код продолжает работать.",
|
||||
"os_failed": "Не получилось. Введите PIN-код.",
|
||||
"lock_title": "{app}: заблокировано",
|
||||
"locking": "Блокировка…",
|
||||
"pin_prompt": "Введите PIN-код, чтобы продолжить.",
|
||||
"unlock": "Разблокировать",
|
||||
"unlocking": "Разблокировка…",
|
||||
"wrong_pin": "Этот PIN-код неверный.",
|
||||
"unlock_failed": "Не удалось разблокировать. Попробуйте ещё раз.",
|
||||
"blocked_later": "Слишком много попыток. Попробуйте позже.",
|
||||
"forgot": "Забыли PIN-код?",
|
||||
"forgot_title": "Удалить локальные данные и выйти?",
|
||||
"forgot_body": "Забытый PIN-код нельзя восстановить. Будет удалена почта, хранящаяся на этом устройстве, и выполнен выход из всех учётных записей, чтобы вы могли войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.",
|
||||
"forgot_provider": "Если вы вошли через страницу входа вашей организации, этот вход там остаётся открытым. Следующий вход здесь запросит у этой страницы новый вход, если она это позволяет.",
|
||||
"forgot_confirm": "Удалить и выйти",
|
||||
"unlock_in_main": "Разблокируйте в главном окне.",
|
||||
"open_main": "Перейти в главное окно",
|
||||
"setup_required_title": "Ваша организация требует блокировку приложения",
|
||||
"setup_required_body": "Выберите PIN-код. Он понадобится, чтобы открыть {app} после блокировки.",
|
||||
"draft_saved_notice": "Написанное было сохранено в черновиках перед блокировкой.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# минута} few {# минуты} many {# минут} other {# минуты}}",
|
||||
"blocked_seconds": "Слишком много попыток. Повторите через {count, plural, one {# секунду} few {# секунды} many {# секунд} other {# секунды}}.",
|
||||
"blocked_minutes": "Слишком много попыток. Повторите через {count, plural, one {# минуту} few {# минуты} many {# минут} other {# минуты}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Войти через браузер",
|
||||
"browser_waiting": "Завершите вход в браузере",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Zavrieť"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Zámok aplikácie",
|
||||
"section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.",
|
||||
"enable_label": "Zamykať kódom PIN",
|
||||
"enable_desc": "V predvolenom stave vypnuté. PIN zostáva na tomto zariadení.",
|
||||
"protects_desc": "Pred čím to chráni: v prehliadači PIN nepustí do aplikácie nikoho pri tomto zariadení a pošta uložená tu zostáva šifrovaná. Nezastaví program bežiaci na tomto počítači, rozšírenie prehliadača, ktoré ste nainštalovali, ani niekoho, kto skopíruje súbory prehliadača a má čas skúšať veľa PIN-ov, preto sa dlhší PIN oplatí. V desktopovej aplikácii je PIN navyše viazaný na vaše prihlásenie na tomto počítači. Váš účet na serveri to neovplyvní.",
|
||||
"enable_body": "Zvoľte PIN. Budete ho potrebovať pri každom zamknutí aplikácie a po opätovnom načítaní alebo novom spustení.",
|
||||
"set_pin_title": "Zvoľte PIN",
|
||||
"change_pin": "Zmeniť PIN…",
|
||||
"change_pin_title": "Zmeniť PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "Súčasný PIN",
|
||||
"pin_new": "Nový PIN",
|
||||
"pin_repeat": "Zopakujte PIN",
|
||||
"pin_hint": "Aspoň 6 znakov. Fungujú aj písmená a dlhší PIN sa ťažšie uhádne.",
|
||||
"pin_obvious": "Tento PIN sa dá príliš ľahko uhádnuť. Vyhnite sa opakovaniu, radám a bežným voľbám.",
|
||||
"pin_mismatch": "Oba kódy PIN nie sú rovnaké.",
|
||||
"pin_recovery": "Ak ho zabudnete, jedinou cestou je odstrániť údaje z tohto zariadenia a znova sa prihlásiť. Vaša pošta na serveri zostáva. Správy čakajúce na odoslanie na tomto zariadení sa stratia.",
|
||||
"save": "Uložiť",
|
||||
"cancel": "Zrušiť",
|
||||
"save_failed": "Nepodarilo sa to. Skúste to znova.",
|
||||
"timeout_label": "Zamknúť po",
|
||||
"timeout_desc": "Čas bez používania v ktoromkoľvek okne alebo karte.",
|
||||
"lock_now": "Zamknúť teraz",
|
||||
"lock_now_desc": "Zamkne hneď všetky okná a karty.",
|
||||
"disable_title": "Vypnúť zámok aplikácie?",
|
||||
"disable_body": "Na vypnutie zadajte PIN.",
|
||||
"disable_confirm": "Vypnúť",
|
||||
"passkey_label": "Prístupový kľúč",
|
||||
"passkey_desc": "Odomknite prístupovým alebo bezpečnostným kľúčom, ktorý vie chrániť kľúč. Nie všetky to vedia a PIN ďalej funguje.",
|
||||
"passkey_add": "Pridať prístupový kľúč…",
|
||||
"passkey_add_title": "Pridať prístupový kľúč",
|
||||
"passkey_remove": "Odstrániť",
|
||||
"passkey_added": "Prístupový kľúč bol pridaný.",
|
||||
"passkey_no_prf": "Tento prístupový kľúč nemôže chrániť kľúč. PIN ďalej funguje.",
|
||||
"passkey_failed": "Prístupový kľúč nefungoval. Zadajte PIN.",
|
||||
"use_passkey": "Použiť prístupový kľúč",
|
||||
"use_hello": "Použiť Windows Hello",
|
||||
"use_touchid": "Použiť Touch ID",
|
||||
"os_add": "Zapnúť…",
|
||||
"os_add_title": "Použiť {method}",
|
||||
"os_remove": "Vypnúť",
|
||||
"os_desc": "{method} je kontrola, ktorú robí aplikácia. Po potvrdení aplikácia prečíta kľúč zo šifrovaného úložiska tohto počítača. Program bežiaci pod vaším používateľským účtom by ho tam mohol prečítať aj bez toho, takže PIN chráni viac. PIN ďalej funguje.",
|
||||
"os_failed": "Nepodarilo sa to. Zadajte PIN.",
|
||||
"lock_title": "{app} je zamknutá",
|
||||
"locking": "Zamyká sa…",
|
||||
"pin_prompt": "Pokračujte zadaním kódu PIN.",
|
||||
"unlock": "Odomknúť",
|
||||
"unlocking": "Odomyká sa…",
|
||||
"wrong_pin": "Tento PIN nie je správny.",
|
||||
"unlock_failed": "Odomknutie sa nepodarilo. Skúste to znova.",
|
||||
"blocked_later": "Príliš veľa pokusov. Skúste to neskôr.",
|
||||
"forgot": "Zabudli ste PIN?",
|
||||
"forgot_title": "Odstrániť miestne údaje a odhlásiť sa?",
|
||||
"forgot_body": "Zabudnutý PIN sa nedá obnoviť. Odstráni sa tým pošta uložená na tomto zariadení a odhlásia sa všetky účty, aby ste sa mohli znova prihlásiť. Vaša pošta na serveri zostáva. Správy čakajúce na odoslanie na tomto zariadení sa stratia.",
|
||||
"forgot_provider": "Ak ste sa prihlásili cez prihlasovaciu stránku svojej organizácie, prihlásenie tam zostane otvorené. Ďalšie prihlásenie tu požiada túto stránku o nové prihlásenie, ak to umožňuje.",
|
||||
"forgot_confirm": "Odstrániť a odhlásiť",
|
||||
"unlock_in_main": "Odomknite v hlavnom okne.",
|
||||
"open_main": "Prejsť do hlavného okna",
|
||||
"setup_required_title": "Vaša organizácia vyžaduje zámok aplikácie",
|
||||
"setup_required_body": "Zvoľte PIN. Budete ho potrebovať na otvorenie {app} po zamknutí.",
|
||||
"draft_saved_notice": "To, čo ste písali, sa pred zamknutím uložilo do konceptov.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# minúta} few {# minúty} many {# minúty} other {# minút}}",
|
||||
"blocked_seconds": "Príliš veľa pokusov. Skúste to o {count, plural, one {# sekundu} few {# sekundy} many {# sekundy} other {# sekúnd}}.",
|
||||
"blocked_minutes": "Príliš veľa pokusov. Skúste to o {count, plural, one {# minútu} few {# minúty} many {# minúty} other {# minút}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Prihlásiť sa v prehliadači",
|
||||
"browser_waiting": "Dokončite prihlásenie v prehliadači",
|
||||
|
||||
@@ -6770,6 +6770,74 @@
|
||||
"close": "ปิด"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "ล็อกแอป",
|
||||
"section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ",
|
||||
"enable_label": "ล็อกด้วย PIN",
|
||||
"enable_desc": "ปิดอยู่โดยค่าเริ่มต้น PIN จะอยู่บนอุปกรณ์นี้",
|
||||
"protects_desc": "สิ่งที่ป้องกันได้: ในเบราว์เซอร์ PIN กันคนที่อยู่หน้าอุปกรณ์นี้ไม่ให้เข้าแอป และเก็บอีเมลที่บันทึกไว้ที่นี่แบบเข้ารหัส แต่หยุดโปรแกรมที่ทำงานบนคอมพิวเตอร์เครื่องนี้ ส่วนขยายเบราว์เซอร์ที่คุณติดตั้ง หรือคนที่คัดลอกไฟล์ของเบราว์เซอร์แล้วมีเวลาลอง PIN จำนวนมากไม่ได้ จึงควรใช้ PIN ที่ยาวขึ้น ในแอปเดสก์ท็อป PIN ยังผูกกับการเข้าสู่ระบบคอมพิวเตอร์เครื่องนี้ด้วย บัญชีของคุณบนเซิร์ฟเวอร์ไม่ได้รับผลกระทบ",
|
||||
"enable_body": "เลือก PIN คุณต้องใช้ทุกครั้งที่แอปล็อก และหลังโหลดใหม่หรือเริ่มใหม่",
|
||||
"set_pin_title": "เลือก PIN",
|
||||
"change_pin": "เปลี่ยน PIN…",
|
||||
"change_pin_title": "เปลี่ยน PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "PIN ปัจจุบัน",
|
||||
"pin_new": "PIN ใหม่",
|
||||
"pin_repeat": "ใส่ PIN อีกครั้ง",
|
||||
"pin_hint": "อย่างน้อย 6 ตัวอักษร ใช้ตัวอักษรได้ และ PIN ที่ยาวกว่าเดาได้ยากกว่า",
|
||||
"pin_obvious": "PIN นี้เดาง่ายเกินไป หลีกเลี่ยงการซ้ำ ตัวเลขเรียง และรหัสที่คนใช้บ่อย",
|
||||
"pin_mismatch": "PIN ทั้งสองไม่ตรงกัน",
|
||||
"pin_recovery": "หากลืม วิธีเดียวคือลบข้อมูลบนอุปกรณ์นี้แล้วลงชื่อเข้าใช้ใหม่ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป",
|
||||
"save": "บันทึก",
|
||||
"cancel": "ยกเลิก",
|
||||
"save_failed": "ไม่สำเร็จ ลองอีกครั้ง",
|
||||
"timeout_label": "ล็อกหลังจาก",
|
||||
"timeout_desc": "เวลาที่ไม่ได้ใช้งานในหน้าต่างหรือแท็บใดก็ตาม",
|
||||
"lock_now": "ล็อกเดี๋ยวนี้",
|
||||
"lock_now_desc": "ล็อกทุกหน้าต่างและแท็บทันที",
|
||||
"disable_title": "ปิดการล็อกแอปหรือไม่",
|
||||
"disable_body": "ใส่ PIN เพื่อปิด",
|
||||
"disable_confirm": "ปิด",
|
||||
"passkey_label": "พาสคีย์",
|
||||
"passkey_desc": "ปลดล็อกด้วยพาสคีย์หรือคีย์ความปลอดภัยที่ปกป้องกุญแจได้ ไม่ใช่ทุกอันที่ทำได้ และ PIN ยังใช้ได้",
|
||||
"passkey_add": "เพิ่มพาสคีย์…",
|
||||
"passkey_add_title": "เพิ่มพาสคีย์",
|
||||
"passkey_remove": "ลบ",
|
||||
"passkey_added": "เพิ่มพาสคีย์แล้ว",
|
||||
"passkey_no_prf": "พาสคีย์นี้ปกป้องกุญแจไม่ได้ PIN ยังใช้ได้",
|
||||
"passkey_failed": "พาสคีย์ใช้ไม่ได้ ใส่ PIN แทน",
|
||||
"use_passkey": "ใช้พาสคีย์",
|
||||
"use_hello": "ใช้ Windows Hello",
|
||||
"use_touchid": "ใช้ Touch ID",
|
||||
"os_add": "เปิด…",
|
||||
"os_add_title": "ใช้ {method}",
|
||||
"os_remove": "ปิด",
|
||||
"os_desc": "{method} เป็นการตรวจสอบโดยแอป หลังจากยืนยัน แอปจะอ่านกุญแจจากที่เก็บแบบเข้ารหัสของคอมพิวเตอร์นี้ โปรแกรมที่ทำงานภายใต้บัญชีผู้ใช้ของคุณอาจอ่านได้โดยไม่ต้องใช้ {method} ดังนั้น PIN จึงปกป้องได้มากกว่า PIN ยังใช้ได้",
|
||||
"os_failed": "ไม่สำเร็จ ใส่ PIN แทน",
|
||||
"lock_title": "{app} ถูกล็อก",
|
||||
"locking": "กำลังล็อก…",
|
||||
"pin_prompt": "ใส่ PIN เพื่อดำเนินการต่อ",
|
||||
"unlock": "ปลดล็อก",
|
||||
"unlocking": "กำลังปลดล็อก…",
|
||||
"wrong_pin": "PIN นี้ไม่ถูกต้อง",
|
||||
"unlock_failed": "ปลดล็อกไม่สำเร็จ ลองอีกครั้ง",
|
||||
"blocked_later": "ลองหลายครั้งเกินไป ลองอีกครั้งภายหลัง",
|
||||
"forgot": "ลืม PIN หรือไม่",
|
||||
"forgot_title": "ลบข้อมูลในเครื่องและออกจากระบบหรือไม่",
|
||||
"forgot_body": "PIN ที่ลืมไม่สามารถกู้คืนได้ การดำเนินการนี้จะลบอีเมลที่เก็บบนอุปกรณ์นี้และออกจากระบบทุกบัญชี เพื่อให้คุณลงชื่อเข้าใช้ใหม่ได้ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป",
|
||||
"forgot_provider": "หากคุณลงชื่อเข้าใช้ผ่านหน้าเข้าสู่ระบบขององค์กร การเข้าสู่ระบบนั้นจะยังเปิดอยู่ที่นั่น การลงชื่อเข้าใช้ครั้งถัดไปที่นี่จะขอให้หน้านั้นเข้าสู่ระบบใหม่ หากหน้านั้นอนุญาต",
|
||||
"forgot_confirm": "ลบและออกจากระบบ",
|
||||
"unlock_in_main": "ปลดล็อกในหน้าต่างหลัก",
|
||||
"open_main": "ไปที่หน้าต่างหลัก",
|
||||
"setup_required_title": "องค์กรของคุณกำหนดให้ใช้การล็อกแอป",
|
||||
"setup_required_body": "เลือก PIN คุณต้องใช้เพื่อเปิด {app} หลังจากล็อก",
|
||||
"draft_saved_notice": "สิ่งที่คุณเขียนถูกบันทึกในแบบร่างก่อนล็อก",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, other {# นาที}}",
|
||||
"blocked_seconds": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# วินาที}}",
|
||||
"blocked_minutes": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# นาที}}"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
|
||||
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Kapat"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Uygulama kilidi",
|
||||
"section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.",
|
||||
"enable_label": "PIN ile kilitle",
|
||||
"enable_desc": "Varsayılan olarak kapalı. PIN bu cihazda kalır.",
|
||||
"protects_desc": "Neye karşı korur: Tarayıcıda PIN, bu cihazın başındaki birini uygulamadan uzak tutar ve burada saklanan postayı şifreli tutar. Bu bilgisayarda çalışan bir programı, yüklediğiniz bir tarayıcı uzantısını veya tarayıcının dosyalarını kopyalayıp çok sayıda PIN deneyecek zamanı olan birini durduramaz, bu yüzden daha uzun bir PIN'e değer. Masaüstü uygulamasında PIN, bu bilgisayardaki oturumunuza da bağlıdır. Sunucudaki hesabınız etkilenmez.",
|
||||
"enable_body": "Bir PIN seçin. Uygulama her kilitlendiğinde ve yeniden yükleme ya da yeni başlatma sonrasında gerekir.",
|
||||
"set_pin_title": "PIN seçin",
|
||||
"change_pin": "PIN'i değiştir…",
|
||||
"change_pin_title": "PIN'i değiştir",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "Geçerli PIN",
|
||||
"pin_new": "Yeni PIN",
|
||||
"pin_repeat": "PIN'i tekrarlayın",
|
||||
"pin_hint": "En az 6 karakter. Harfler de olur ve daha uzun bir PIN'i tahmin etmek daha zordur.",
|
||||
"pin_obvious": "Bu PIN'i tahmin etmek çok kolay. Tekrarlardan, sıralı dizilerden ve yaygın seçimlerden kaçının.",
|
||||
"pin_mismatch": "İki PIN aynı değil.",
|
||||
"pin_recovery": "Unutursanız tek çözüm bu cihazdaki verileri kaldırıp yeniden oturum açmaktır. Sunucudaki e-postalarınız kalır. Bu cihazda hâlâ gönderilmeyi bekleyen iletiler kaybolur.",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"save_failed": "Olmadı. Yeniden deneyin.",
|
||||
"timeout_label": "Şu süre sonra kilitle",
|
||||
"timeout_desc": "Herhangi bir pencerede veya sekmede kullanılmayan süre.",
|
||||
"lock_now": "Şimdi kilitle",
|
||||
"lock_now_desc": "Tüm pencereleri ve sekmeleri hemen kilitler.",
|
||||
"disable_title": "Uygulama kilidi kapatılsın mı?",
|
||||
"disable_body": "Kapatmak için PIN'inizi girin.",
|
||||
"disable_confirm": "Kapat",
|
||||
"passkey_label": "Geçiş anahtarı",
|
||||
"passkey_desc": "Anahtarı koruyabilen bir geçiş anahtarı veya güvenlik anahtarıyla açın. Hepsi koruyamaz ve PIN çalışmaya devam eder.",
|
||||
"passkey_add": "Geçiş anahtarı ekle…",
|
||||
"passkey_add_title": "Geçiş anahtarı ekle",
|
||||
"passkey_remove": "Kaldır",
|
||||
"passkey_added": "Geçiş anahtarı eklendi.",
|
||||
"passkey_no_prf": "Bu geçiş anahtarı anahtarı koruyamaz. PIN'iniz çalışmaya devam eder.",
|
||||
"passkey_failed": "Geçiş anahtarı çalışmadı. PIN'inizi girin.",
|
||||
"use_passkey": "Geçiş anahtarı kullan",
|
||||
"use_hello": "Windows Hello kullan",
|
||||
"use_touchid": "Touch ID kullan",
|
||||
"os_add": "Aç…",
|
||||
"os_add_title": "{method} kullan",
|
||||
"os_remove": "Kapat",
|
||||
"os_desc": "{method}, uygulamanın yaptığı bir denetimdir. Onaydan sonra uygulama anahtarı bu bilgisayarın şifreli deposundan okur. Kullanıcı hesabınızda çalışan bir program onu orada {method} olmadan da okuyabilir, bu yüzden PIN daha çok korur. PIN çalışmaya devam eder.",
|
||||
"os_failed": "Olmadı. PIN'inizi girin.",
|
||||
"lock_title": "{app} kilitli",
|
||||
"locking": "Kilitleniyor…",
|
||||
"pin_prompt": "Devam etmek için PIN'inizi girin.",
|
||||
"unlock": "Kilidi aç",
|
||||
"unlocking": "Kilit açılıyor…",
|
||||
"wrong_pin": "Bu PIN doğru değil.",
|
||||
"unlock_failed": "Kilit açılamadı. Yeniden deneyin.",
|
||||
"blocked_later": "Çok fazla deneme. Daha sonra yeniden deneyin.",
|
||||
"forgot": "PIN'inizi mi unuttunuz?",
|
||||
"forgot_title": "Yerel veriler kaldırılıp oturum kapatılsın mı?",
|
||||
"forgot_body": "Unutulan PIN kurtarılamaz. Bu işlem bu cihazda tutulan e-postaları kaldırır ve yeniden oturum açabilmeniz için tüm hesapların oturumunu kapatır. Sunucudaki e-postalarınız kalır. Bu cihazda hâlâ gönderilmeyi bekleyen iletiler kaybolur.",
|
||||
"forgot_provider": "Kuruluşunuzun giriş sayfasından oturum açtıysanız o oturum orada açık kalır. Buradaki bir sonraki oturum açma, mümkünse o sayfadan yeni bir giriş ister.",
|
||||
"forgot_confirm": "Kaldır ve oturumu kapat",
|
||||
"unlock_in_main": "Ana pencerede kilidi açın.",
|
||||
"open_main": "Ana pencereye git",
|
||||
"setup_required_title": "Kuruluşunuz uygulama kilidi istiyor",
|
||||
"setup_required_body": "Bir PIN seçin. {app} kilitlendikten sonra açmak için gerekir.",
|
||||
"draft_saved_notice": "Yazdıklarınız kilitlenmeden önce Taslaklar'a kaydedildi.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# dakika} other {# dakika}}",
|
||||
"blocked_seconds": "Çok fazla deneme. {count, plural, one {# saniye} other {# saniye}} sonra yeniden deneyin.",
|
||||
"blocked_minutes": "Çok fazla deneme. {count, plural, one {# dakika} other {# dakika}} sonra yeniden deneyin."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Tarayıcıyla oturum aç",
|
||||
"browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "Закрити"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Блокування застосунку",
|
||||
"section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.",
|
||||
"enable_label": "Блокувати PIN-кодом",
|
||||
"enable_desc": "Типово вимкнено. PIN-код залишається на цьому пристрої.",
|
||||
"protects_desc": "Від чого це захищає: у браузері PIN-код не пускає до застосунку того, хто біля цього пристрою, і зберігає пошту тут зашифрованою. Він не зупинить програму на цьому комп'ютері, встановлене вами розширення браузера або того, хто скопіює файли браузера і матиме час перебирати багато кодів, тому варто обрати довший PIN-код. У настільному застосунку PIN-код також прив'язаний до вашого входу на цьому комп'ютері. Ваш обліковий запис на сервері це не зачіпає.",
|
||||
"enable_body": "Виберіть PIN-код. Він знадобиться щоразу, коли застосунок блокується, а також після перезавантаження чи нового запуску.",
|
||||
"set_pin_title": "Виберіть PIN-код",
|
||||
"change_pin": "Змінити PIN-код…",
|
||||
"change_pin_title": "Змінити PIN-код",
|
||||
"pin_label": "PIN-код",
|
||||
"pin_current": "Поточний PIN-код",
|
||||
"pin_new": "Новий PIN-код",
|
||||
"pin_repeat": "Повторіть PIN-код",
|
||||
"pin_hint": "Щонайменше 6 символів. Можна вводити літери, а довший PIN-код важче вгадати.",
|
||||
"pin_obvious": "Цей PIN-код надто легко вгадати. Уникайте повторів, послідовностей і поширених варіантів.",
|
||||
"pin_mismatch": "PIN-коди не збігаються.",
|
||||
"pin_recovery": "Якщо ви його забудете, залишиться лише вилучити дані на цьому пристрої й увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.",
|
||||
"save": "Зберегти",
|
||||
"cancel": "Скасувати",
|
||||
"save_failed": "Не вдалося. Спробуйте ще раз.",
|
||||
"timeout_label": "Блокувати через",
|
||||
"timeout_desc": "Час без використання в будь-якому вікні чи вкладці.",
|
||||
"lock_now": "Заблокувати зараз",
|
||||
"lock_now_desc": "Одразу блокує всі вікна й вкладки.",
|
||||
"disable_title": "Вимкнути блокування застосунку?",
|
||||
"disable_body": "Введіть PIN-код, щоб вимкнути його.",
|
||||
"disable_confirm": "Вимкнути",
|
||||
"passkey_label": "Ключ доступу",
|
||||
"passkey_desc": "Розблокування ключем доступу або ключем безпеки, що може захистити ключ. Не всі можуть, а PIN-код продовжує працювати.",
|
||||
"passkey_add": "Додати ключ доступу…",
|
||||
"passkey_add_title": "Додати ключ доступу",
|
||||
"passkey_remove": "Вилучити",
|
||||
"passkey_added": "Ключ доступу додано.",
|
||||
"passkey_no_prf": "Цей ключ доступу не може захистити ключ. PIN-код продовжує працювати.",
|
||||
"passkey_failed": "Ключ доступу не спрацював. Введіть PIN-код.",
|
||||
"use_passkey": "Використати ключ доступу",
|
||||
"use_hello": "Використати Windows Hello",
|
||||
"use_touchid": "Використати Touch ID",
|
||||
"os_add": "Увімкнути…",
|
||||
"os_add_title": "Використати {method}",
|
||||
"os_remove": "Вимкнути",
|
||||
"os_desc": "{method} — це перевірка, яку виконує застосунок. Після підтвердження застосунок читає ключ із зашифрованого сховища цього комп'ютера. Програма під вашим обліковим записом могла б прочитати його там і без {method}, тому PIN-код захищає краще. PIN-код продовжує працювати.",
|
||||
"os_failed": "Не вдалося. Введіть PIN-код.",
|
||||
"lock_title": "{app}: заблоковано",
|
||||
"locking": "Блокування…",
|
||||
"pin_prompt": "Введіть PIN-код, щоб продовжити.",
|
||||
"unlock": "Розблокувати",
|
||||
"unlocking": "Розблокування…",
|
||||
"wrong_pin": "Цей PIN-код неправильний.",
|
||||
"unlock_failed": "Не вдалося розблокувати. Спробуйте ще раз.",
|
||||
"blocked_later": "Забагато спроб. Спробуйте пізніше.",
|
||||
"forgot": "Забули PIN-код?",
|
||||
"forgot_title": "Вилучити локальні дані й вийти?",
|
||||
"forgot_body": "Забутий PIN-код не можна відновити. Буде вилучено пошту, збережену на цьому пристрої, і виконано вихід з усіх облікових записів, щоб ви могли увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.",
|
||||
"forgot_provider": "Якщо ви увійшли через сторінку входу вашої організації, цей вхід там залишається відкритим. Наступний вхід тут попросить цю сторінку про новий вхід, якщо вона це дозволяє.",
|
||||
"forgot_confirm": "Вилучити й вийти",
|
||||
"unlock_in_main": "Розблокуйте в головному вікні.",
|
||||
"open_main": "Перейти до головного вікна",
|
||||
"setup_required_title": "Ваша організація вимагає блокування застосунку",
|
||||
"setup_required_body": "Виберіть PIN-код. Він знадобиться, щоб відкрити {app} після блокування.",
|
||||
"draft_saved_notice": "Написане було збережено в чернетках перед блокуванням.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, one {# хвилина} few {# хвилини} many {# хвилин} other {# хвилини}}",
|
||||
"blocked_seconds": "Забагато спроб. Повторіть через {count, plural, one {# секунду} few {# секунди} many {# секунд} other {# секунди}}.",
|
||||
"blocked_minutes": "Забагато спроб. Повторіть через {count, plural, one {# хвилину} few {# хвилини} many {# хвилин} other {# хвилини}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Увійти через браузер",
|
||||
"browser_waiting": "Завершіть вхід у браузері",
|
||||
|
||||
@@ -6774,6 +6774,74 @@
|
||||
"close": "Đóng"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Khóa ứng dụng",
|
||||
"section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.",
|
||||
"enable_label": "Khóa bằng mã PIN",
|
||||
"enable_desc": "Mặc định tắt. Mã PIN chỉ ở trên thiết bị này.",
|
||||
"protects_desc": "Điều này bảo vệ trước gì: trong trình duyệt, mã PIN ngăn người ở cạnh thiết bị này vào ứng dụng và giữ thư lưu ở đây được mã hóa. Nó không thể chặn một chương trình chạy trên máy tính này, tiện ích trình duyệt bạn đã cài, hoặc người sao chép tệp của trình duyệt và có thời gian thử nhiều mã, vì vậy mã PIN dài hơn là đáng giá. Trong ứng dụng máy tính, mã PIN còn gắn với thông tin đăng nhập của bạn trên máy tính này. Tài khoản của bạn trên máy chủ không bị ảnh hưởng.",
|
||||
"enable_body": "Hãy chọn một mã PIN. Bạn cần nó mỗi khi ứng dụng khóa, và sau khi tải lại hoặc khởi động lại.",
|
||||
"set_pin_title": "Chọn mã PIN",
|
||||
"change_pin": "Đổi mã PIN…",
|
||||
"change_pin_title": "Đổi mã PIN",
|
||||
"pin_label": "Mã PIN",
|
||||
"pin_current": "Mã PIN hiện tại",
|
||||
"pin_new": "Mã PIN mới",
|
||||
"pin_repeat": "Nhập lại mã PIN",
|
||||
"pin_hint": "Ít nhất 6 ký tự. Có thể dùng cả chữ cái, và mã dài hơn sẽ khó đoán hơn.",
|
||||
"pin_obvious": "Mã PIN này quá dễ đoán. Hãy tránh lặp lại, dãy số và các lựa chọn phổ biến.",
|
||||
"pin_mismatch": "Hai mã PIN không giống nhau.",
|
||||
"pin_recovery": "Nếu quên, cách duy nhất là xóa dữ liệu trên thiết bị này và đăng nhập lại. Thư của bạn trên máy chủ vẫn còn. Các thư đang chờ gửi trên thiết bị này sẽ bị mất.",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy",
|
||||
"save_failed": "Không thành công. Hãy thử lại.",
|
||||
"timeout_label": "Khóa sau",
|
||||
"timeout_desc": "Thời gian không dùng ở bất kỳ cửa sổ hoặc thẻ nào.",
|
||||
"lock_now": "Khóa ngay",
|
||||
"lock_now_desc": "Khóa ngay mọi cửa sổ và thẻ.",
|
||||
"disable_title": "Tắt khóa ứng dụng?",
|
||||
"disable_body": "Nhập mã PIN để tắt.",
|
||||
"disable_confirm": "Tắt",
|
||||
"passkey_label": "Khóa truy cập",
|
||||
"passkey_desc": "Mở khóa bằng khóa truy cập hoặc khóa bảo mật có thể bảo vệ khóa. Không phải loại nào cũng làm được, và mã PIN vẫn dùng được.",
|
||||
"passkey_add": "Thêm khóa truy cập…",
|
||||
"passkey_add_title": "Thêm khóa truy cập",
|
||||
"passkey_remove": "Gỡ bỏ",
|
||||
"passkey_added": "Đã thêm khóa truy cập.",
|
||||
"passkey_no_prf": "Khóa truy cập này không thể bảo vệ khóa. Mã PIN vẫn dùng được.",
|
||||
"passkey_failed": "Khóa truy cập không hoạt động. Hãy nhập mã PIN.",
|
||||
"use_passkey": "Dùng khóa truy cập",
|
||||
"use_hello": "Dùng Windows Hello",
|
||||
"use_touchid": "Dùng Touch ID",
|
||||
"os_add": "Bật…",
|
||||
"os_add_title": "Dùng {method}",
|
||||
"os_remove": "Tắt",
|
||||
"os_desc": "{method} là bước kiểm tra do ứng dụng thực hiện. Sau khi xác nhận, ứng dụng đọc khóa từ bộ nhớ mã hóa của máy tính này. Một chương trình chạy dưới tài khoản người dùng của bạn có thể đọc nó ở đó mà không cần {method}, nên mã PIN bảo vệ tốt hơn. Mã PIN vẫn dùng được.",
|
||||
"os_failed": "Không thành công. Hãy nhập mã PIN.",
|
||||
"lock_title": "{app} đang khóa",
|
||||
"locking": "Đang khóa…",
|
||||
"pin_prompt": "Nhập mã PIN để tiếp tục.",
|
||||
"unlock": "Mở khóa",
|
||||
"unlocking": "Đang mở khóa…",
|
||||
"wrong_pin": "Mã PIN này không đúng.",
|
||||
"unlock_failed": "Không mở khóa được. Hãy thử lại.",
|
||||
"blocked_later": "Quá nhiều lần thử. Hãy thử lại sau.",
|
||||
"forgot": "Quên mã PIN?",
|
||||
"forgot_title": "Xóa dữ liệu cục bộ và đăng xuất?",
|
||||
"forgot_body": "Mã PIN đã quên không thể khôi phục. Thao tác này xóa thư lưu trên thiết bị này và đăng xuất mọi tài khoản để bạn có thể đăng nhập lại. Thư của bạn trên máy chủ vẫn còn. Các thư đang chờ gửi trên thiết bị này sẽ bị mất.",
|
||||
"forgot_provider": "Nếu bạn đăng nhập qua trang đăng nhập của tổ chức, phiên đó vẫn mở ở đó. Lần đăng nhập tiếp theo tại đây sẽ yêu cầu trang đó đăng nhập mới, nếu được phép.",
|
||||
"forgot_confirm": "Xóa và đăng xuất",
|
||||
"unlock_in_main": "Hãy mở khóa trong cửa sổ chính.",
|
||||
"open_main": "Đến cửa sổ chính",
|
||||
"setup_required_title": "Tổ chức của bạn yêu cầu khóa ứng dụng",
|
||||
"setup_required_body": "Hãy chọn một mã PIN. Bạn cần nó để mở {app} sau khi khóa.",
|
||||
"draft_saved_notice": "Nội dung bạn đang viết đã được lưu vào Thư nháp trước khi khóa.",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, other {# phút}}",
|
||||
"blocked_seconds": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# giây}}.",
|
||||
"blocked_minutes": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# phút}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Đăng nhập bằng trình duyệt",
|
||||
"browser_waiting": "Hoàn tất đăng nhập trong trình duyệt",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "關閉"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "應用程式鎖定",
|
||||
"section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。",
|
||||
"enable_label": "使用 PIN 鎖定",
|
||||
"enable_desc": "預設關閉。PIN 只保存在此裝置上。",
|
||||
"protects_desc": "它能防護什麼:在瀏覽器中,PIN 可阻止這台裝置前的人進入應用程式,並讓儲存在此處的郵件保持加密。它無法阻止在這台電腦上執行的程式、你安裝的瀏覽器擴充功能,或複製瀏覽器檔案並有時間嘗試大量 PIN 的人,因此較長的 PIN 更值得。在桌面應用程式中,PIN 也與你在這台電腦上的登入綁定。伺服器上的帳戶不受影響。",
|
||||
"enable_body": "請選擇一個 PIN。每次應用程式鎖定時以及重新載入或重新啟動後都需要它。",
|
||||
"set_pin_title": "選擇 PIN",
|
||||
"change_pin": "變更 PIN…",
|
||||
"change_pin_title": "變更 PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "目前的 PIN",
|
||||
"pin_new": "新 PIN",
|
||||
"pin_repeat": "重複 PIN",
|
||||
"pin_hint": "至少 6 個字元。也可以使用字母,較長的 PIN 更難猜到。",
|
||||
"pin_obvious": "這個 PIN 太容易被猜到。請避免重複、連續數字和常見組合。",
|
||||
"pin_mismatch": "兩個 PIN 不一致。",
|
||||
"pin_recovery": "如果忘記了,唯一的辦法是移除此裝置上的資料並重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"save_failed": "未成功。請再試一次。",
|
||||
"timeout_label": "鎖定時間",
|
||||
"timeout_desc": "任一視窗或分頁未使用的時間。",
|
||||
"lock_now": "立即鎖定",
|
||||
"lock_now_desc": "立即鎖定所有視窗和分頁。",
|
||||
"disable_title": "關閉應用程式鎖定?",
|
||||
"disable_body": "輸入 PIN 以將其關閉。",
|
||||
"disable_confirm": "關閉",
|
||||
"passkey_label": "密碼金鑰",
|
||||
"passkey_desc": "使用能夠保護金鑰的密碼金鑰或安全金鑰解鎖。並非全部都支援,PIN 仍然可用。",
|
||||
"passkey_add": "新增密碼金鑰…",
|
||||
"passkey_add_title": "新增密碼金鑰",
|
||||
"passkey_remove": "移除",
|
||||
"passkey_added": "已新增密碼金鑰。",
|
||||
"passkey_no_prf": "此密碼金鑰無法保護金鑰。PIN 仍然可用。",
|
||||
"passkey_failed": "密碼金鑰無法運作。請輸入 PIN。",
|
||||
"use_passkey": "使用密碼金鑰",
|
||||
"use_hello": "使用 Windows Hello",
|
||||
"use_touchid": "使用 Touch ID",
|
||||
"os_add": "開啟…",
|
||||
"os_add_title": "使用 {method}",
|
||||
"os_remove": "關閉",
|
||||
"os_desc": "{method} 是應用程式進行的檢查。確認後,應用程式從此電腦的加密儲存空間讀取金鑰。在您的使用者帳戶下執行的程式不需 {method} 也能讀取,因此 PIN 的保護更強。PIN 仍然可用。",
|
||||
"os_failed": "未成功。請輸入 PIN。",
|
||||
"lock_title": "{app} 已鎖定",
|
||||
"locking": "正在鎖定…",
|
||||
"pin_prompt": "輸入 PIN 以繼續。",
|
||||
"unlock": "解鎖",
|
||||
"unlocking": "正在解鎖…",
|
||||
"wrong_pin": "此 PIN 不正確。",
|
||||
"unlock_failed": "解鎖未成功。請再試一次。",
|
||||
"blocked_later": "嘗試次數過多。請稍後再試。",
|
||||
"forgot": "忘記了 PIN?",
|
||||
"forgot_title": "移除本機資料並登出?",
|
||||
"forgot_body": "忘記的 PIN 無法復原。這會移除此裝置上保存的郵件並登出所有帳戶,讓您可以重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。",
|
||||
"forgot_provider": "如果你透過組織的登入頁面登入,該登入會在那裡保持開啟。下次在此登入時,會在允許的情況下要求該頁面重新登入。",
|
||||
"forgot_confirm": "移除並登出",
|
||||
"unlock_in_main": "請在主視窗中解鎖。",
|
||||
"open_main": "前往主視窗",
|
||||
"setup_required_title": "您的組織要求使用應用程式鎖定",
|
||||
"setup_required_body": "請選擇一個 PIN。鎖定後開啟 {app} 時需要它。",
|
||||
"draft_saved_notice": "您正在撰寫的內容已在鎖定前儲存到草稿。",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, other {# 分鐘}}",
|
||||
"blocked_seconds": "嘗試次數過多。請在 {count, plural, other {# 秒}}後再試。",
|
||||
"blocked_minutes": "嘗試次數過多。請在 {count, plural, other {# 分鐘}}後再試。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "使用瀏覽器登入",
|
||||
"browser_waiting": "請在瀏覽器中完成登入",
|
||||
|
||||
@@ -6762,6 +6762,74 @@
|
||||
"close": "关闭"
|
||||
}
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "应用锁",
|
||||
"section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。",
|
||||
"enable_label": "使用 PIN 锁定",
|
||||
"enable_desc": "默认关闭。PIN 只保存在此设备上。",
|
||||
"protects_desc": "它能防护什么:在浏览器中,PIN 可阻止这台设备前的人进入应用,并让存储在此处的邮件保持加密。它无法阻止在这台电脑上运行的程序、你安装的浏览器扩展,或复制浏览器文件并有时间尝试大量 PIN 的人,因此更长的 PIN 更值得。在桌面应用中,PIN 还与你在这台电脑上的登录绑定。服务器上的账户不受影响。",
|
||||
"enable_body": "请选择一个 PIN。每次应用锁定时以及重新加载或重新启动后都需要它。",
|
||||
"set_pin_title": "选择 PIN",
|
||||
"change_pin": "更改 PIN…",
|
||||
"change_pin_title": "更改 PIN",
|
||||
"pin_label": "PIN",
|
||||
"pin_current": "当前 PIN",
|
||||
"pin_new": "新 PIN",
|
||||
"pin_repeat": "重复 PIN",
|
||||
"pin_hint": "至少 6 个字符。也可以使用字母,较长的 PIN 更难猜到。",
|
||||
"pin_obvious": "这个 PIN 太容易被猜到。请避免重复、连续数字和常见组合。",
|
||||
"pin_mismatch": "两个 PIN 不一致。",
|
||||
"pin_recovery": "如果忘记了,唯一的办法是删除此设备上的数据并重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"save_failed": "未成功。请重试。",
|
||||
"timeout_label": "锁定时间",
|
||||
"timeout_desc": "任一窗口或标签页未使用的时间。",
|
||||
"lock_now": "立即锁定",
|
||||
"lock_now_desc": "立即锁定所有窗口和标签页。",
|
||||
"disable_title": "关闭应用锁?",
|
||||
"disable_body": "输入 PIN 以将其关闭。",
|
||||
"disable_confirm": "关闭",
|
||||
"passkey_label": "通行密钥",
|
||||
"passkey_desc": "使用能够保护密钥的通行密钥或安全密钥解锁。并非全部都支持,PIN 仍然可用。",
|
||||
"passkey_add": "添加通行密钥…",
|
||||
"passkey_add_title": "添加通行密钥",
|
||||
"passkey_remove": "移除",
|
||||
"passkey_added": "已添加通行密钥。",
|
||||
"passkey_no_prf": "此通行密钥无法保护密钥。PIN 仍然可用。",
|
||||
"passkey_failed": "通行密钥不起作用。请输入 PIN。",
|
||||
"use_passkey": "使用通行密钥",
|
||||
"use_hello": "使用 Windows Hello",
|
||||
"use_touchid": "使用 Touch ID",
|
||||
"os_add": "打开…",
|
||||
"os_add_title": "使用 {method}",
|
||||
"os_remove": "关闭",
|
||||
"os_desc": "{method} 是应用进行的检查。确认后,应用从此计算机的加密存储中读取密钥。在您的用户帐户下运行的程序无需 {method} 也能读取,因此 PIN 的保护更强。PIN 仍然可用。",
|
||||
"os_failed": "未成功。请输入 PIN。",
|
||||
"lock_title": "{app} 已锁定",
|
||||
"locking": "正在锁定…",
|
||||
"pin_prompt": "输入 PIN 以继续。",
|
||||
"unlock": "解锁",
|
||||
"unlocking": "正在解锁…",
|
||||
"wrong_pin": "此 PIN 不正确。",
|
||||
"unlock_failed": "解锁未成功。请重试。",
|
||||
"blocked_later": "尝试次数过多。请稍后再试。",
|
||||
"forgot": "忘记了 PIN?",
|
||||
"forgot_title": "删除本地数据并退出登录?",
|
||||
"forgot_body": "忘记的 PIN 无法恢复。这会删除此设备上保存的邮件并退出所有帐户,以便您重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。",
|
||||
"forgot_provider": "如果你通过组织的登录页面登录,该登录会在那里保持打开。下次在此登录时,会在允许的情况下要求该页面重新登录。",
|
||||
"forgot_confirm": "删除并退出登录",
|
||||
"unlock_in_main": "请在主窗口中解锁。",
|
||||
"open_main": "转到主窗口",
|
||||
"setup_required_title": "您的组织要求使用应用锁",
|
||||
"setup_required_body": "请选择一个 PIN。锁定后打开 {app} 时需要它。",
|
||||
"draft_saved_notice": "您正在撰写的内容已在锁定前保存到草稿。",
|
||||
"use_hello_name": "Windows Hello",
|
||||
"use_touchid_name": "Touch ID",
|
||||
"timeout_option": "{count, plural, other {# 分钟}}",
|
||||
"blocked_seconds": "尝试次数过多。请在 {count, plural, other {# 秒}}后重试。",
|
||||
"blocked_minutes": "尝试次数过多。请在 {count, plural, other {# 分钟}}后重试。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "使用浏览器登录",
|
||||
"browser_waiting": "请在浏览器中完成登录",
|
||||
|
||||
+54
-1
@@ -223,6 +223,8 @@ function offlineRoute(request) {
|
||||
if (request.method !== "GET") return null;
|
||||
const url = new URL(request.url);
|
||||
if (request.mode === "navigate" && appPagePath(url) !== null) return respondWithDocument;
|
||||
// The app lock asks whether a reload would land anywhere, and has to hear what a reload would.
|
||||
if (request.headers.get("x-bulwark-reload-probe") && appPagePath(url) !== null) return respondWithDocument;
|
||||
if (staticPath(url)) return respondWithStatic;
|
||||
if (url.origin === self.location.origin && url.pathname === BASE_PATH + "/api/config") return respondWithConfig;
|
||||
return null;
|
||||
@@ -506,6 +508,10 @@ self.addEventListener("message", (event) => {
|
||||
event.waitUntil(recordClientLogins(event.source && event.source.id, data.logins));
|
||||
return;
|
||||
}
|
||||
if (data.type === "app-lock") {
|
||||
event.waitUntil(writeAppLockState(data.state));
|
||||
return;
|
||||
}
|
||||
if (data.type === "offline-shell") {
|
||||
// The switch is kept before the event ends. The shell's files are
|
||||
// fetched without holding the event open: a worker busy with an event
|
||||
@@ -614,7 +620,9 @@ async function handlePush(event) {
|
||||
// a generic "New mail" so the user still sees something.
|
||||
let preview = null;
|
||||
let previewOk = false;
|
||||
try {
|
||||
// Locked: not a word about the message, so it isn't even looked up.
|
||||
const lockHidesContent = await appLockHidesContent();
|
||||
if (!lockHidesContent) try {
|
||||
const query = new URLSearchParams();
|
||||
if (accountId) query.set("accountId", accountId);
|
||||
if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]);
|
||||
@@ -777,6 +785,49 @@ async function showWithoutAlert(groupTag) {
|
||||
// every so often.
|
||||
const QUIET_WINDOW_MS = 30_000;
|
||||
|
||||
// ---- App lock: while locked a notification says nothing about the message ----
|
||||
|
||||
function appLockStateKey() {
|
||||
return `${self.location.origin}${BASE_PATH}/__push-state/app-lock`;
|
||||
}
|
||||
|
||||
async function writeAppLockState(state) {
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
await cache.put(appLockStateKey(), new Response(JSON.stringify({ state }), { headers: { "Content-Type": "application/json" } }));
|
||||
if (state === "locked") await closeMailNotifications();
|
||||
}
|
||||
|
||||
/** What's on screen from before the lock names senders and subjects. */
|
||||
async function closeMailNotifications() {
|
||||
try {
|
||||
const shown = await self.registration.getNotifications();
|
||||
for (const notification of shown) {
|
||||
if (typeof notification.tag === "string" && notification.tag.startsWith("bulwark-mail:")) notification.close();
|
||||
}
|
||||
} catch (_) {
|
||||
// Nothing more a worker can do.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page says "locked" when it locks. It can't say so when the browser just closes, so
|
||||
* "unlocked" only counts with a window open: the key is in a window's memory or nowhere.
|
||||
*/
|
||||
async function appLockHidesContent() {
|
||||
try {
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const res = await cache.match(appLockStateKey());
|
||||
const stored = res ? await res.json() : null;
|
||||
if (!stored || stored.state === "off") return false;
|
||||
if (stored.state !== "unlocked") return true;
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
return windows.length === 0;
|
||||
} catch (_) {
|
||||
// Can't tell: a generic notification loses little.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function lastAlertKey() {
|
||||
return `${self.location.origin}${BASE_PATH}/__push-state/last-alert`;
|
||||
}
|
||||
@@ -1366,6 +1417,8 @@ async function postNotificationAction(action, data) {
|
||||
|
||||
/** True when the action is done and nothing has to open. */
|
||||
async function runNotificationAction(action, data, reply, allClients) {
|
||||
// An old notification's button doesn't act on mail while the app is locked.
|
||||
if (await appLockHidesContent()) return false;
|
||||
const text = action === "reply" && typeof reply === "string" ? reply.trim() : "";
|
||||
const { result, client } = await askOpenClients({
|
||||
kind: "notification-action",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A forgotten PIN offline: the HttpOnly session cookie couldn't be deleted. With no
|
||||
// accounts left, a start would adopt that cookie and sign its login back in, with no PIN.
|
||||
|
||||
vi.mock('@/lib/store/client/purge', () => ({ purgeStoreFiles: async () => {}, deleteStorePool: async () => {} }));
|
||||
|
||||
let useAuthStore: typeof import('../auth-store').useAuthStore;
|
||||
|
||||
function calls(): string[] {
|
||||
return (fetch as ReturnType<typeof vi.fn>).mock.calls.map(([url, init]) => `${(init as RequestInit)?.method ?? 'GET'} ${String(url)}`);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
({ useAuthStore } = await import('../auth-store'));
|
||||
const browserNavigation = await import('@/lib/browser-navigation');
|
||||
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('the mark for cookies still to clear', () => {
|
||||
it('blocks adoption while the DELETE keeps failing, and tries it first', async () => {
|
||||
localStorage.setItem('bulwark-cookies-to-clear', '1');
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'DELETE') throw new TypeError('offline');
|
||||
return new Response(JSON.stringify({ serverUrl: 'https://mail.example.test', username: 'alice', password: 'secret' }), { status: 200 });
|
||||
}));
|
||||
await useAuthStore.getState().checkAuth();
|
||||
expect(calls()).toContain('DELETE /api/auth/session?all=true');
|
||||
expect(calls()).not.toContain('PUT /api/auth/session');
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(localStorage.getItem('bulwark-cookies-to-clear')).toBe('1');
|
||||
});
|
||||
|
||||
it('comes off when the DELETE worked, and nothing is adopted from what was cleared', async () => {
|
||||
localStorage.setItem('bulwark-cookies-to-clear', '1');
|
||||
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => (
|
||||
init?.method === 'PUT' ? new Response('{}', { status: 401 }) : new Response('{}', { status: 200 })
|
||||
)));
|
||||
await useAuthStore.getState().checkAuth();
|
||||
expect(localStorage.getItem('bulwark-cookies-to-clear')).toBeNull();
|
||||
const list = calls();
|
||||
expect(list.indexOf('DELETE /api/auth/session?all=true')).toBeGreaterThanOrEqual(0);
|
||||
expect(list.indexOf('DELETE /api/auth/session?all=true')).toBeLessThan(list.indexOf('PUT /api/auth/session'));
|
||||
});
|
||||
|
||||
it('isn\'t there normally: adoption goes on as before', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 401 })));
|
||||
await useAuthStore.getState().checkAuth();
|
||||
expect(calls()).toContain('PUT /api/auth/session');
|
||||
expect(calls()).not.toContain('DELETE /api/auth/session?all=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the app lock and the way to the login page', () => {
|
||||
const stored = JSON.stringify({ state: { isAuthenticated: true }, version: 0 });
|
||||
|
||||
it('a page that starts behind the lock has shown nothing, so the login page opens in place', async () => {
|
||||
vi.resetModules();
|
||||
localStorage.setItem('auth-storage', stored);
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
|
||||
const auth = await import('../auth-store');
|
||||
expect(auth.useAuthStore.getState().isAuthenticated, 'what storage said').toBe(true);
|
||||
expect(auth.canEnterLoginInPlace()).toBe(true);
|
||||
});
|
||||
|
||||
it('with no lock, a signed-in state from storage still counts as shown', async () => {
|
||||
vi.resetModules();
|
||||
localStorage.setItem('auth-storage', stored);
|
||||
const auth = await import('../auth-store');
|
||||
expect(auth.useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(auth.canEnterLoginInPlace()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -225,4 +225,17 @@ describe('a login without "remember me"', () => {
|
||||
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
|
||||
expect(lost.consumeLost()).toEqual(['Unsent']);
|
||||
});
|
||||
|
||||
it('keeps its data when the reload was the app lock\'s: it has to sign in again, and loses nothing', async () => {
|
||||
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: false }));
|
||||
useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), rememberMe: false }] } as never);
|
||||
tokenAnswers(() => answer(200));
|
||||
await useAuthStore.getState().checkAuth();
|
||||
expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined();
|
||||
expect(state.isEvicted(ALICE)).toBe(true);
|
||||
offline.startOfflineMode();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(purge.files).toBe(0);
|
||||
expect(lost.consumeLost()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-5
@@ -25,6 +25,8 @@ import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll
|
||||
import { getDesktopPolicy, onDesktopPolicyChange } from '@/lib/desktop/policy';
|
||||
import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '@/lib/desktop/refused-accounts';
|
||||
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
|
||||
import { clearPendingCookies, freshLoginDone } from '@/lib/app-lock/sign-out';
|
||||
import { isAppLockedNow, isAppLockOn } from '@/lib/app-lock/state';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup';
|
||||
import { forgetPrincipals } from '@/lib/delegation/known-principals';
|
||||
import { useSearchHistoryStore } from './search-history-store';
|
||||
@@ -2994,7 +2996,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
// registry so the normal restoration path picks it up. Without this
|
||||
// the cookies sit unused and the SPA bounces to the login screen.
|
||||
// (Not in Lite: nothing hands sessions over server-side.)
|
||||
if (accounts.length === 0 && !IS_LITE) {
|
||||
// Not while a forgotten PIN's cookies wait to be cleared: this would sign that
|
||||
// login back in with no PIN. The DELETE goes first, and adoption waits for it.
|
||||
if (accounts.length === 0 && !IS_LITE && await clearPendingCookies()) {
|
||||
try {
|
||||
const restore = await apiFetch('/api/auth/session', { method: 'PUT' });
|
||||
if (restore.ok) {
|
||||
@@ -3082,7 +3086,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
forgottenAccounts += 1;
|
||||
evictAccount(account.id);
|
||||
// The user's choice on what may be a shared computer: its local data goes too.
|
||||
accountStore.removeAccount(account.id, 'explicit');
|
||||
// Not when the app lock is on: its reload ended this page, not the session, so
|
||||
// the mail and the outbox wait for the next sign-in.
|
||||
accountStore.removeAccount(account.id, isAppLockOn() ? 'evicted' : 'explicit');
|
||||
if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true);
|
||||
return;
|
||||
}
|
||||
@@ -3603,6 +3609,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
},
|
||||
|
||||
startSharedSession: async (options) => {
|
||||
// Locked: not from another window, and not from the cookies either.
|
||||
if (isAppLockedNow()) return 'no-session';
|
||||
set({ isLoading: true, client: null });
|
||||
const accountStore = useAccountStore.getState();
|
||||
const answer = await requestWindowSession(options?.accountId ? { accountId: options.accountId } : {});
|
||||
@@ -3838,10 +3846,14 @@ if (typeof window !== 'undefined') {
|
||||
onDesktopPolicyChange(applyDesktopServerPolicy);
|
||||
}
|
||||
|
||||
// See canEnterLoginInPlace.
|
||||
let signedInSincePageLoad = useAuthStore.getState().isAuthenticated;
|
||||
// See canEnterLoginInPlace. A page that starts behind the app lock has shown nothing yet,
|
||||
// whatever the stored state says, and a page load here would ask for the PIN again.
|
||||
let signedInSincePageLoad = !isAppLockOn() && useAuthStore.getState().isAuthenticated;
|
||||
useAuthStore.subscribe((state) => {
|
||||
if (state.isAuthenticated) signedInSincePageLoad = true;
|
||||
if (state.isAuthenticated) {
|
||||
signedInSincePageLoad = true;
|
||||
freshLoginDone();
|
||||
}
|
||||
});
|
||||
|
||||
// Another tab signed everyone out: this one's cookies and account list are
|
||||
|
||||
@@ -38,6 +38,7 @@ import { SendCancelledCleanupError } from "@/lib/send-failure-message";
|
||||
import type { StorageQuota } from "@/lib/quota";
|
||||
import { isArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { getOutboxHooks } from "@/lib/offline/outbox-hooks";
|
||||
import { isAppLockOn } from "@/lib/app-lock/state";
|
||||
import { useOfflineStore } from "@/lib/offline/state";
|
||||
import {
|
||||
READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection,
|
||||
@@ -8734,6 +8735,8 @@ if (typeof window !== 'undefined') {
|
||||
if (typeof account !== 'string' || !account) return;
|
||||
// Its mail lives encrypted in the store, and not in plain localStorage too.
|
||||
if (useOfflineStore.getState().enabled.includes(account)) return;
|
||||
// Nor with an app lock, which must not leave subjects readable on disk.
|
||||
if (isAppLockOn()) return;
|
||||
try {
|
||||
window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({
|
||||
v: EMAIL_SNAPSHOT_VERSION,
|
||||
|
||||
@@ -125,7 +125,7 @@ export function recordNotification(input: CenterEntryInput): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper: read the list back from storage as a fresh page load would. */
|
||||
/** Reads the list back from storage as a fresh page load would: the app lock puts it back at unlock. */
|
||||
export function reloadNotificationCenter(): void {
|
||||
useNotificationCenterStore.setState({ entries: load() });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user