mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
fix: keep unsent mail through the idle and session limits, and mark a signed-out account in the switcher
The timers used to delete everything an account kept, unsent mail included. They now follow the refused-login rule: sign out and hide, the next sign-in brings it back, otherwise it goes after 30 days. A login without "Remember me" that ends with the browser, an admin wipe and a wipe from settings don't change. - `wipeOnRefusedLogin` is now `wipeOnForcedSignOut`, since it also covers the limits. The server's `SESSION_TOO_OLD` goes the same way, as `session`. - A refused account beside one that stays signed in shows in the switcher as needing sign-in, with the count and the date, and gets one toast. Existing tests changed because the rule did: the session timer tests expected a plain `logoutAll`, and the auth store tests expected no hook at the session limit.
This commit is contained in:
@@ -1516,9 +1516,10 @@ function LoginPageContent() {
|
||||
)}
|
||||
{refusedWaiting.map((wait) => {
|
||||
const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" });
|
||||
const prefix = wait.kind === "session" ? "session_waiting" : "refused_waiting";
|
||||
return (
|
||||
<div key={wait.until} className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground" role="status" data-testid="login-refused-waiting">
|
||||
{wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })}
|
||||
{wait.unsent === null ? tOfflineWipe(`${prefix}_unknown`, { date }) : tOfflineWipe(prefix, { count: wait.unsent, date })}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -235,6 +235,7 @@ function wipeReasonLabel(reason: NonNullable<DeviceView['wipeReason']>): string
|
||||
switch (reason) {
|
||||
case 'user': return 'by the user';
|
||||
case 'rejected': return 'the server refused the login';
|
||||
case 'session': return 'the session limit ran out';
|
||||
default: return reason;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,18 @@ describe('Offline mode section', () => {
|
||||
|
||||
it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => {
|
||||
await renderSection();
|
||||
const toggle = screen.getByTestId('org-devices-wipeOnRefusedLogin');
|
||||
const toggle = screen.getByTestId('org-devices-wipeOnForcedSignOut');
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
act(() => latest!.setScopeId('field'));
|
||||
fireEvent.click(screen.getByTestId('org-devices-wipeOnRefusedLogin'));
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnRefusedLogin: true } });
|
||||
expect(latest!.policy?.organisation?.devices.wipeOnRefusedLogin).toBe(false);
|
||||
fireEvent.click(screen.getByTestId('org-devices-wipeOnForcedSignOut'));
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnForcedSignOut: true } });
|
||||
expect(latest!.policy?.organisation?.devices.wipeOnForcedSignOut).toBe(false);
|
||||
});
|
||||
|
||||
it('says what a sign-out does now, for the timers too', async () => {
|
||||
await renderSection();
|
||||
expect(document.body.textContent).not.toContain('Every sign-out, the automatic one after inactivity included, deletes everything');
|
||||
expect(document.body.textContent).toContain('A sign-out someone asks for deletes everything');
|
||||
});
|
||||
|
||||
it('writes a scope override only into that scope', async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un
|
||||
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const forbid = draft.orgValue('devices', 'noOffline');
|
||||
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
|
||||
const wipeOnRefused = draft.orgValue('devices', 'wipeOnRefusedLogin');
|
||||
const wipeOnRefused = draft.orgValue('devices', 'wipeOnForcedSignOut');
|
||||
return (
|
||||
<SettingsSection
|
||||
id="offline"
|
||||
@@ -107,12 +107,12 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
</div>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Delete a device's mail as soon as the server refuses its login"
|
||||
description="Off: the device signs out and hides what it keeps, unsent messages included, until someone signs in with the new password, or for 30 days. That's what a password changed on another device needs. On: it's deleted right away, unsent messages included, and only that account's. A wipe you ask for below is immediate either way."
|
||||
label="Delete a device's mail as soon as it's signed out with nobody asking"
|
||||
description="That's when the server refuses its login, or the idle or session limit runs out. Off: the device signs out and hides what it keeps, unsent messages included, until the next sign-in, or for 30 days. That's what a password changed on another device needs. On: it's deleted right away, unsent messages included, and only that account's. A wipe you ask for below is immediate either way."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<ToggleSwitch checked={wipeOnRefused.value} onChange={(v) => draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" />
|
||||
<InheritNote draft={draft} own={wipeOnRefused.own} everyone={wipeOnRefused.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} />
|
||||
<ToggleSwitch checked={wipeOnRefused.value} onChange={(v) => draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" />
|
||||
<InheritNote draft={draft} own={wipeOnRefused.own} everyone={wipeOnRefused.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
@@ -120,8 +120,8 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
|
||||
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
|
||||
scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to
|
||||
them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one
|
||||
after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings.
|
||||
them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything.
|
||||
One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
|
||||
Bulwark Lite reads the same policy but has no offline mode.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
|
||||
How offline mode works
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A refused account beside another that stays signed in: the switcher marks it,
|
||||
// with the count and the date, and takes the user to its sign-in.
|
||||
|
||||
const push = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push, replace: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
|
||||
const { AccountSwitcher } = await import('../account-switcher');
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { markLoginRefused, setRefusedCount, dropRefusedLogin } = await import('@/lib/devices/wipe-state');
|
||||
const { useOfflineStore, markEvicted, clearEvicted } = await import('@/lib/offline/state');
|
||||
|
||||
const A = 'a@example.com';
|
||||
const B = 'b@example.com';
|
||||
|
||||
function account(id: string) {
|
||||
return { id, label: id, email: id, username: id, displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699', cookieSlot: 0, isConnected: true, isDefault: id === A };
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
render(<AccountSwitcher variant="rail" />);
|
||||
fireEvent.click(screen.getByTestId('account-switcher'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
localStorage.clear();
|
||||
useAccountStore.setState({ accounts: [account(A)] } as never);
|
||||
useAuthStore.setState({ activeAccountId: A } as never);
|
||||
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
|
||||
useOfflineStore.setState({ enabled: [A, B] });
|
||||
markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B });
|
||||
markEvicted(B);
|
||||
});
|
||||
|
||||
describe('an account that needs signing in again', () => {
|
||||
it('is marked, with its count and the date its data goes', () => {
|
||||
setRefusedCount(B, 2);
|
||||
open();
|
||||
const row = screen.getByTestId('account-needs-sign-in');
|
||||
expect(row.textContent).toContain(B);
|
||||
expect(row.textContent).toContain('needs_sign_in');
|
||||
expect(row.textContent).toContain('switcher_waiting');
|
||||
});
|
||||
|
||||
it('says only the date when it couldn\'t count, or there\'s nothing unsent', () => {
|
||||
setRefusedCount(B, 0);
|
||||
open();
|
||||
const row = screen.getByTestId('account-needs-sign-in');
|
||||
expect(row.textContent).toContain('switcher_kept');
|
||||
expect(row.textContent).not.toContain('switcher_waiting');
|
||||
});
|
||||
|
||||
it('takes the user to its sign-in, with the name filled in', () => {
|
||||
setRefusedCount(B, 2);
|
||||
open();
|
||||
fireEvent.click(screen.getByTestId('account-needs-sign-in'));
|
||||
expect(push).toHaveBeenCalledWith(`/login?mode=add-account&username=${encodeURIComponent(B)}`);
|
||||
});
|
||||
|
||||
it('shows the count as soon as it\'s known, with the menu open', () => {
|
||||
open();
|
||||
expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_kept');
|
||||
act(() => setRefusedCount(B, 3));
|
||||
expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_waiting');
|
||||
});
|
||||
|
||||
it('isn\'t shown to an account that wasn\'t there', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never);
|
||||
useAccountStore.setState({ accounts: [account('stranger@example.com')] } as never);
|
||||
open();
|
||||
expect(screen.queryByTestId('account-needs-sign-in')).toBeNull();
|
||||
});
|
||||
|
||||
it('is gone once it signed in again', () => {
|
||||
clearEvicted(B);
|
||||
open();
|
||||
expect(screen.queryByTestId('account-needs-sign-in')).toBeNull();
|
||||
dropRefusedLogin(B);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -116,6 +117,11 @@ export function AccountSwitcher({
|
||||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
|
||||
const tWipe = useTranslations("offlineWipe");
|
||||
// An account that signed out by itself beside this one: its data waits.
|
||||
const [, noteWaitsChanged] = useState(0);
|
||||
useEffect(() => onRefusedLoginsChanged(() => noteWaitsChanged((count) => count + 1)), []);
|
||||
const waiting = siblingWaits(activeAccountId);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
@@ -513,6 +519,30 @@ export function AccountSwitcher({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{waiting.map((wait) => (
|
||||
<button
|
||||
key={wait.account}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="account-needs-sign-in"
|
||||
className="w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push(`/login?mode=add-account&username=${encodeURIComponent(wait.username)}` as never);
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-destructive" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium truncate">{toUnicodeEmail(wait.label)}</span>
|
||||
<span className="block text-xs text-foreground">{tWipe("needs_sign_in")}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{wait.unsent
|
||||
? tWipe("switcher_waiting", { count: wait.unsent, date: waitDate(wait.until) })
|
||||
: tWipe("switcher_kept", { date: waitDate(wait.until) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mountain View: the account actions as rows closing the white card,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The one toast when an account beside this one signs out by itself.
|
||||
|
||||
const warning = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { warning } }));
|
||||
vi.mock('@/lib/offline/device-wipe', () => ({ startDeviceWipe: () => () => {} }));
|
||||
|
||||
const { DeviceWipeHost } = await import('../device-wipe-host');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { markLoginRefused, setRefusedCount } = await import('@/lib/devices/wipe-state');
|
||||
const { useOfflineStore, markEvicted } = await import('@/lib/offline/state');
|
||||
|
||||
const A = 'a@example.com';
|
||||
const B = 'b@example.com';
|
||||
|
||||
beforeEach(() => {
|
||||
warning.mockClear();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [A, B] });
|
||||
useAuthStore.setState({ activeAccountId: A, isAuthenticated: true } as never);
|
||||
});
|
||||
|
||||
function refuseB(): void {
|
||||
markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B });
|
||||
markEvicted(B);
|
||||
}
|
||||
|
||||
describe('the toast', () => {
|
||||
it('comes once, when the count is in, and names the account, the count and the date', () => {
|
||||
render(<DeviceWipeHost />);
|
||||
act(() => refuseB());
|
||||
expect(warning).not.toHaveBeenCalled();
|
||||
act(() => setRefusedCount(B, 2));
|
||||
expect(warning).toHaveBeenCalledTimes(1);
|
||||
expect(warning.mock.calls[0][0]).toBe('sibling_waiting');
|
||||
act(() => setRefusedCount(B, 2));
|
||||
expect(warning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says the other thing with nothing unsent, or no number', () => {
|
||||
render(<DeviceWipeHost />);
|
||||
act(() => { refuseB(); setRefusedCount(B, null); });
|
||||
expect(warning.mock.calls[0][0]).toBe('sibling_kept');
|
||||
});
|
||||
|
||||
it('isn\'t for someone signed in later', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never);
|
||||
refuseB();
|
||||
setRefusedCount(B, 2);
|
||||
render(<DeviceWipeHost />);
|
||||
expect(warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows at the next page for the one it was meant for, if it got no chance before', () => {
|
||||
refuseB();
|
||||
setRefusedCount(B, 2);
|
||||
render(<DeviceWipeHost />);
|
||||
expect(warning).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { consumeWipeNote, type WipeNote } from '@/lib/devices/wipe-state';
|
||||
import { consumeWipeNote, onRefusedLoginsChanged, takeSiblingToasts, waitDate, type WipeNote } from '@/lib/devices/wipe-state';
|
||||
import { startDeviceWipe } from '@/lib/offline/device-wipe';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
@@ -33,5 +33,17 @@ export function DeviceWipeHost() {
|
||||
if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 });
|
||||
}, [signedIn]);
|
||||
|
||||
// An account beside this one signed out by itself: once, when it's known what waits.
|
||||
useEffect(() => {
|
||||
const look = () => {
|
||||
for (const wait of takeSiblingToasts(useAuthStore.getState().activeAccountId)) {
|
||||
const values = { name: wait.label, date: waitDate(wait.until) };
|
||||
toast.warning(wait.unsent ? tRef.current('sibling_waiting', { ...values, count: wait.unsent }) : tRef.current('sibling_kept', values), { duration: 0 });
|
||||
}
|
||||
};
|
||||
look();
|
||||
return onRefusedLoginsChanged(look);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ describe('session timeout guard', () => {
|
||||
await advance(500);
|
||||
|
||||
expect(order).toEqual(['draft saved', 'signed out']);
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('session timeout guard', () => {
|
||||
await advance(DRAFT_FLUSH_TIMEOUT_MS - 1);
|
||||
expect(logoutAll).not.toHaveBeenCalled();
|
||||
await advance(1);
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
});
|
||||
|
||||
it('uses the strictest of the signed-in accounts', async () => {
|
||||
@@ -258,7 +258,7 @@ describe('session timeout guard', () => {
|
||||
expect(screen.queryByText('max_title')).toBeNull();
|
||||
await advance(60 * SECOND);
|
||||
await advance(0);
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
|
||||
});
|
||||
|
||||
@@ -276,7 +276,7 @@ describe('session timeout guard', () => {
|
||||
fireEvent.pointerDown(window);
|
||||
await advance(0);
|
||||
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
|
||||
});
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('session timeout guard', () => {
|
||||
// Later in the body than the overlay, so it paints on top at a higher z-index too.
|
||||
expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
await advance(30 * SECOND);
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
|
||||
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
} finally {
|
||||
overlay.remove();
|
||||
hasFocus.mockRestore();
|
||||
|
||||
@@ -92,7 +92,8 @@ const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: n
|
||||
* (policy.json `organisation.session`). Activity in any Bulwark tab keeps
|
||||
* every tab signed in; a warning counts down first; at zero the open and
|
||||
* minimised composers save their drafts and every account signs out, with
|
||||
* the reason on the login page.
|
||||
* the reason on the login page. What the accounts keep on the device hides until
|
||||
* the next sign-in, unless the organisation asks for deletion at once.
|
||||
*/
|
||||
export function SessionTimeoutGuard() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -149,7 +150,8 @@ export function SessionTimeoutGuard() {
|
||||
rememberSessionEnd(endReason(kind, current, false));
|
||||
const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
|
||||
if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true));
|
||||
await useAuthStore.getState().logoutAll({ endProviderSession: false });
|
||||
// Nobody asked: the mail hides until the next sign-in, and unsent messages with it.
|
||||
await useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
}, []);
|
||||
|
||||
// The clock, while there is a limit to count.
|
||||
|
||||
@@ -7,15 +7,15 @@ import { sanitizeOrganisationPartial } from '../organisation-policy';
|
||||
|
||||
const scope = (id: string, devices: Record<string, unknown>) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } });
|
||||
|
||||
describe('wipeOnRefusedLogin', () => {
|
||||
describe('wipeOnForcedSignOut', () => {
|
||||
it('is off unless asked for, and survives the sanitizer', () => {
|
||||
expect(DEFAULT_POLICY.organisation?.devices?.wipeOnRefusedLogin ?? false).toBe(false);
|
||||
expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: true } }).devices).toMatchObject({ wipeOnRefusedLogin: true });
|
||||
expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: 'yes' } }).devices?.wipeOnRefusedLogin).not.toBe(true);
|
||||
expect(DEFAULT_POLICY.organisation?.devices?.wipeOnForcedSignOut ?? false).toBe(false);
|
||||
expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: true } }).devices).toMatchObject({ wipeOnForcedSignOut: true });
|
||||
expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: 'yes' } }).devices?.wipeOnForcedSignOut).not.toBe(true);
|
||||
});
|
||||
|
||||
it('isn\'t set by the stand-in for a login nobody can read: that\'s for what stays on the server', () => {
|
||||
expect(ALL_DEVICE_LIMITS.wipeOnRefusedLogin).toBe(false);
|
||||
expect(ALL_DEVICE_LIMITS.wipeOnForcedSignOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -176,11 +176,11 @@ export interface OrganisationPolicy {
|
||||
/** All attachments and files of one account on a device, in MB. 0 is no cap. */
|
||||
offlineMaxTotalMb: number;
|
||||
/**
|
||||
* A device deletes an account's offline data as soon as the server refuses its
|
||||
* login, instead of hiding it for 30 days until the new password. Unsent mail
|
||||
* goes with it.
|
||||
* A device deletes an account's offline data as soon as it signs out with nobody
|
||||
* asking (the server refused the login, or the idle or session limit ran out),
|
||||
* instead of hiding it for 30 days until the next sign-in. Unsent mail goes with it.
|
||||
*/
|
||||
wipeOnRefusedLogin: boolean;
|
||||
wipeOnForcedSignOut: boolean;
|
||||
};
|
||||
calendars: {
|
||||
/** Adds up across scopes by id. */
|
||||
@@ -206,7 +206,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
|
||||
noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false,
|
||||
pushWithoutContent: false, watermark: false, noOffline: false,
|
||||
offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0,
|
||||
wipeOnRefusedLogin: false,
|
||||
wipeOnForcedSignOut: false,
|
||||
},
|
||||
calendars: { feeds: [], freeBusyGroup: '' },
|
||||
};
|
||||
@@ -551,7 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<Organisat
|
||||
noOfflineAttachments: bool(dev.noOfflineAttachments),
|
||||
offlineMaxAttachmentMb: count(dev.offlineMaxAttachmentMb, MAX_OFFLINE_MB),
|
||||
offlineMaxTotalMb: count(dev.offlineMaxTotalMb, MAX_OFFLINE_MB),
|
||||
wipeOnRefusedLogin: bool(dev.wipeOnRefusedLogin),
|
||||
wipeOnForcedSignOut: bool(dev.wipeOnForcedSignOut),
|
||||
});
|
||||
|
||||
const cal = section('calendars');
|
||||
|
||||
@@ -93,7 +93,7 @@ export const ALL_DEVICE_LIMITS: ServerDevicePolicy = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [key, typeof off === 'number' ? 1 : true]),
|
||||
),
|
||||
wipeOnRefusedLogin: false,
|
||||
wipeOnForcedSignOut: false,
|
||||
} as ServerDevicePolicy;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
* admin and what a device says about its own wipe. No secrets, no store.
|
||||
*/
|
||||
|
||||
/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */
|
||||
export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const;
|
||||
/**
|
||||
* Who asked for a wipe. `rejected`: the server refused the stored login, and
|
||||
* `session`: the idle or session limit ran out, both where the organisation asks
|
||||
* for deletion at once.
|
||||
*/
|
||||
export const WIPE_REASONS = ['admin', 'user', 'rejected', 'session'] as const;
|
||||
export type WipeReason = (typeof WIPE_REASONS)[number];
|
||||
|
||||
export interface DeviceView {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* is a count, a row id and the secret for that one row.
|
||||
*/
|
||||
|
||||
import type { ForcedSignOutKind } from '@/lib/offline/credentials-rejected';
|
||||
import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state';
|
||||
import type { WipeReason } from './types';
|
||||
|
||||
@@ -107,7 +108,7 @@ export function consumeWipeNote(): WipeNote | null {
|
||||
return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent };
|
||||
}
|
||||
|
||||
// ---- A refused login: what waits for the new password. A count, never subjects ----
|
||||
// ---- A login ended with nobody asking: what waits for the next sign-in. A count, never subjects ----
|
||||
|
||||
export interface RefusedLogin {
|
||||
at: number;
|
||||
@@ -115,6 +116,23 @@ export interface RefusedLogin {
|
||||
unsent: number | null;
|
||||
/** Not one bad answer at a start: what a deletion the organisation asked for needs. */
|
||||
certain: boolean;
|
||||
/** Absent: `refused`. */
|
||||
kind?: ForcedSignOutKind;
|
||||
/** The count has been asked for, whatever came back. */
|
||||
counted?: boolean;
|
||||
/** Accounts signed in beside it at the time: they may see its name, and nobody else does. */
|
||||
seenBy?: string[];
|
||||
label?: string;
|
||||
username?: string;
|
||||
toasted?: boolean;
|
||||
}
|
||||
|
||||
export interface RefusedLoginMark {
|
||||
certain?: boolean;
|
||||
kind?: ForcedSignOutKind;
|
||||
seenBy?: string[];
|
||||
label?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export function readRefusedLogins(): Record<string, RefusedLogin> {
|
||||
@@ -134,14 +152,24 @@ function writeRefused(refused: Record<string, RefusedLogin>): void {
|
||||
for (const listener of [...refusedListeners]) listener();
|
||||
}
|
||||
|
||||
export function markLoginRefused(account: string, certain = true, now = Date.now()): void {
|
||||
export function markLoginRefused(account: string, mark: RefusedLoginMark = {}, now = Date.now()): void {
|
||||
const before = readRefusedLogins();
|
||||
writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } });
|
||||
const { certain = true, kind = 'refused', seenBy, label, username } = mark;
|
||||
writeRefused({
|
||||
...before,
|
||||
[account]: {
|
||||
at: before[account]?.at ?? now,
|
||||
unsent: null,
|
||||
certain: certain || before[account]?.certain === true,
|
||||
kind,
|
||||
...(seenBy?.length ? { seenBy, label, username } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function setRefusedCount(account: string, unsent: number | null): void {
|
||||
const before = readRefusedLogins();
|
||||
if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } });
|
||||
if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent, counted: true } });
|
||||
}
|
||||
|
||||
export function dropRefusedLogin(account: string): void {
|
||||
@@ -155,6 +183,7 @@ export interface RefusedWait {
|
||||
unsent: number | null;
|
||||
/** When the data goes if nobody signs in. */
|
||||
until: number;
|
||||
kind: ForcedSignOutKind;
|
||||
}
|
||||
|
||||
/** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */
|
||||
@@ -162,7 +191,51 @@ export function refusedWaits(): RefusedWait[] {
|
||||
const evicted = readEvicted();
|
||||
return Object.entries(readRefusedLogins())
|
||||
.filter(([account, wait]) => account in evicted && wait.unsent !== 0)
|
||||
.map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS }));
|
||||
.map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS, kind: wait.kind ?? 'refused' }));
|
||||
}
|
||||
|
||||
export interface SiblingWait extends RefusedWait {
|
||||
account: string;
|
||||
label: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
function asSiblingWait(account: string, wait: RefusedLogin, evictedAt: number): SiblingWait {
|
||||
return {
|
||||
account,
|
||||
label: wait.label ?? account,
|
||||
username: wait.username ?? '',
|
||||
unsent: wait.unsent,
|
||||
until: evictedAt + EVICTED_KEEP_MS,
|
||||
kind: wait.kind ?? 'refused',
|
||||
};
|
||||
}
|
||||
|
||||
/** For the account switcher: accounts that left while `active` was signed in beside them. Nobody else sees their names. */
|
||||
export function siblingWaits(active: string | null | undefined): SiblingWait[] {
|
||||
if (!active) return [];
|
||||
const evicted = readEvicted();
|
||||
return Object.entries(readRefusedLogins())
|
||||
.filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active))
|
||||
.map(([account, wait]) => asSiblingWait(account, wait, evicted[account]));
|
||||
}
|
||||
|
||||
/** Once each: what the toast says when such an account leaves. Waits for the count, so it can say it. */
|
||||
export function takeSiblingToasts(active: string | null | undefined): SiblingWait[] {
|
||||
if (!active) return [];
|
||||
const evicted = readEvicted();
|
||||
const refused = readRefusedLogins();
|
||||
const due = Object.entries(refused)
|
||||
.filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active) && wait.counted && !wait.toasted);
|
||||
if (due.length === 0) return [];
|
||||
writeRefused({ ...refused, ...Object.fromEntries(due.map(([account, wait]) => [account, { ...wait, toasted: true }])) });
|
||||
return due.map(([account, wait]) => asSiblingWait(account, wait, evicted[account]));
|
||||
}
|
||||
|
||||
/** A date for these notes, in the page's language. */
|
||||
export function waitDate(until: number): string {
|
||||
const language = typeof document === 'undefined' ? undefined : document.documentElement.lang || undefined;
|
||||
return new Date(until).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
// ---- Plugin databases a running frame kept open: tried again at the next load ----
|
||||
|
||||
@@ -447,14 +447,14 @@ describe('check-ins while running', () => {
|
||||
|
||||
/** What the auth store does around the hook. */
|
||||
function refuse(account = ALICE, certain = true): void {
|
||||
wipeState.markLoginRefused(account, certain);
|
||||
wipeState.markLoginRefused(account, { certain });
|
||||
rejected.noteCredentialsRejected(account, certain);
|
||||
useAccountStore.getState().removeAccount(account, 'evicted');
|
||||
}
|
||||
|
||||
async function policyAsksForDeletion(): Promise<void> {
|
||||
const { usePolicyStore } = await import('@/stores/policy-store');
|
||||
const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } };
|
||||
const policy = { organisation: { devices: { wipeOnForcedSignOut: true } } };
|
||||
usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never);
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => {
|
||||
|
||||
// What the sign-in page says, in numbers only.
|
||||
const [wait] = wipeState.refusedWaits();
|
||||
expect(wait).toEqual({ unsent: 1, until: expect.any(Number) });
|
||||
expect(wait).toEqual({ unsent: 1, until: expect.any(Number), kind: 'refused' });
|
||||
expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS);
|
||||
expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000);
|
||||
expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"');
|
||||
@@ -682,15 +682,16 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => {
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]);
|
||||
});
|
||||
|
||||
it('the session limit stays out of it: no hook, and the data waits', async () => {
|
||||
it('the session limit hides too, as what it is, not as a refusal', async () => {
|
||||
wipe.registerDeviceWipe();
|
||||
token(() => json(401, { error: 'session_expired', maxHours: 8 }));
|
||||
await useAuthStore.getState().refreshAccessToken({ accountId: ALICE });
|
||||
expect(heard).toEqual([]);
|
||||
expect(heard).toEqual([ALICE]);
|
||||
await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true));
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined());
|
||||
expect(purge.files).toBe(0);
|
||||
expect(store.removed).toEqual([]);
|
||||
expect(wipeState.readRefusedLogins()).toEqual({});
|
||||
expect(wipeState.readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1146,3 +1147,127 @@ describe('what else goes with a wipe', () => {
|
||||
expect(unsubscribed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the session timers', () => {
|
||||
const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
|
||||
|
||||
it('leave the outbox whole, and a sign-in brings it back', async () => {
|
||||
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
|
||||
wipe.registerDeviceWipe();
|
||||
const lost = await import('../lost');
|
||||
const at = Date.now();
|
||||
await timerSignOut();
|
||||
|
||||
expect(useAccountStore.getState().accounts).toEqual([]);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(state.isEvicted(ALICE)).toBe(true);
|
||||
expect(state.visibleAccounts()).toEqual([]);
|
||||
expect(purge.files).toBe(0);
|
||||
expect(store.removed).toEqual([]);
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]);
|
||||
expect(lost.consumeLost()).toEqual([]);
|
||||
await vi.waitFor(() => expect(wipeState.refusedWaits()).toEqual([{ unsent: 1, until: expect.any(Number), kind: 'session' }]));
|
||||
|
||||
const offline = await import('../offline-mode');
|
||||
await offline.sweepAccountsGone(at + 14 * DAY);
|
||||
expect(purge.files).toBe(0);
|
||||
|
||||
useAccountStore.getState().addAccount(entry(ALICE, 0) as never);
|
||||
expect(state.visibleAccounts()).toEqual([ALICE]);
|
||||
expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']);
|
||||
expect(wipeState.refusedWaits()).toEqual([]);
|
||||
await wipe.sweepRefusedLogins();
|
||||
expect(wipeState.readRefusedLogins()).toEqual({});
|
||||
});
|
||||
|
||||
it('with no sign-in for 30 days it goes', async () => {
|
||||
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
|
||||
wipe.registerDeviceWipe();
|
||||
const at = Date.now();
|
||||
await timerSignOut();
|
||||
const offline = await import('../offline-mode');
|
||||
await offline.sweepAccountsGone(at + 31 * DAY);
|
||||
expect(purge.files).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keep S/MIME and OpenPGP keys and the row: an admin can still reach the device', async () => {
|
||||
await openDb('smime-plugin-store');
|
||||
wipe.registerDeviceWipe();
|
||||
await timerSignOut();
|
||||
expect(await databases()).toContain('smime-plugin-store');
|
||||
expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW);
|
||||
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('with the organisation asking for deletion, it goes at once, only that account\'s, named as the session limit', async () => {
|
||||
await policyAsksForDeletion();
|
||||
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
|
||||
await openDb('smime-plugin-store');
|
||||
wipe.registerDeviceWipe();
|
||||
await timerSignOut();
|
||||
await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'session', id: ROW }));
|
||||
await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({}));
|
||||
expect(state.useOfflineStore.getState().enabled).toEqual([]);
|
||||
expect(await databases()).toContain('smime-plugin-store');
|
||||
});
|
||||
|
||||
it('a sign-out the user asks for still deletes', async () => {
|
||||
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
|
||||
await useAuthStore.getState().logoutAll({ endProviderSession: false });
|
||||
expect(state.isEvicted(ALICE)).toBe(false);
|
||||
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
|
||||
expect(wipeState.readRefusedLogins()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('a refused account with another still signed in', () => {
|
||||
beforeEach(async () => {
|
||||
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
|
||||
state.writeEnabled([ALICE, BOB]);
|
||||
await keys.openAccountKey(BOB);
|
||||
await queue.queueSend(BOB, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
|
||||
});
|
||||
|
||||
/** What the auth store writes down, with who was there to read it. */
|
||||
function refuseBob(): void {
|
||||
wipeState.markLoginRefused(BOB, { certain: true, seenBy: [ALICE], label: BOB, username: 'bob' });
|
||||
rejected.noteCredentialsRejected(BOB, true);
|
||||
useAccountStore.getState().removeAccount(BOB, 'evicted');
|
||||
}
|
||||
|
||||
it('is there for the one who was signed in beside it, with its count and date', async () => {
|
||||
wipe.registerDeviceWipe();
|
||||
const at = Date.now();
|
||||
refuseBob();
|
||||
await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1));
|
||||
const [wait] = wipeState.siblingWaits(ALICE);
|
||||
expect(wait).toMatchObject({ label: BOB, username: 'bob', kind: 'refused' });
|
||||
expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS);
|
||||
});
|
||||
|
||||
it('isn\'t for anyone who signs in here later, and goes when the account is back', async () => {
|
||||
wipe.registerDeviceWipe();
|
||||
refuseBob();
|
||||
await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)).toHaveLength(1));
|
||||
expect(wipeState.siblingWaits('someone-else')).toEqual([]);
|
||||
useAccountStore.getState().addAccount(entry(BOB, 1) as never);
|
||||
expect(wipeState.siblingWaits(ALICE)).toEqual([]);
|
||||
});
|
||||
|
||||
it('says so once, when the count is known', async () => {
|
||||
wipe.registerDeviceWipe();
|
||||
expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]);
|
||||
refuseBob();
|
||||
expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]);
|
||||
await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1));
|
||||
expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ label: BOB, unsent: 1 })]);
|
||||
expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]);
|
||||
});
|
||||
|
||||
it('says so with no number when the store can\'t give one', async () => {
|
||||
store.failArea = () => true;
|
||||
wipe.registerDeviceWipe();
|
||||
refuseBob();
|
||||
await vi.waitFor(() => expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ unsent: null })]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* The server refused this login: `invalid_grant`, a lasting 401 after a fresh try
|
||||
* (both `certain`), or a 401 at a connect that then failed (not, it's one answer).
|
||||
* The auth store follows with a sign-out that hides the account's data, which
|
||||
* waits for the new password. The organisation's session limit, an outage or any
|
||||
* other refusal doesn't come here. lib/offline/device-wipe.ts listens, to keep the
|
||||
* count for the sign-in page and to delete at once where the organisation asks for
|
||||
* that. Free of the stores, so the auth store can call it.
|
||||
* A login ended with nobody asking. `refused`: the server refused it, by
|
||||
* `invalid_grant` or a lasting 401 after a fresh try (both `certain`), or a 401 at a
|
||||
* connect that then failed (not, it's one answer). `session`: the organisation's
|
||||
* idle or session limit. The auth store follows with a sign-out that hides the
|
||||
* account's data, which waits for the next sign-in. An outage or any other refusal
|
||||
* doesn't come here. lib/offline/device-wipe.ts listens, to keep the count for the
|
||||
* sign-in page and to delete at once where the organisation asks for that. Free of
|
||||
* the stores, so the auth store can call it.
|
||||
*/
|
||||
|
||||
type Listener = (accountId: string, certain: boolean) => void;
|
||||
export type ForcedSignOutKind = 'refused' | 'session';
|
||||
|
||||
type Listener = (accountId: string, certain: boolean, kind: ForcedSignOutKind) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
@@ -17,10 +20,10 @@ export function onCredentialsRejected(listener: Listener): () => void {
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function noteCredentialsRejected(accountId: string, certain = true): void {
|
||||
export function noteCredentialsRejected(accountId: string, certain = true, kind: ForcedSignOutKind = 'refused'): void {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(accountId, certain);
|
||||
listener(accountId, certain, kind);
|
||||
} catch {
|
||||
// A sign-out must go on whatever a listener does.
|
||||
}
|
||||
|
||||
+18
-16
@@ -5,11 +5,11 @@
|
||||
* - the instance's registry (lib/devices): a wipe an admin asked for, heard at
|
||||
* check-in. At a start the check-in runs before the local copy is shown.
|
||||
* - the user, from settings.
|
||||
* - the organisation, when it asks for deletion at once after a refused login
|
||||
* (`organisation.devices.wipeOnRefusedLogin`).
|
||||
* - the organisation, when it asks for deletion at once after a refused login or
|
||||
* an idle or session limit (`organisation.devices.wipeOnForcedSignOut`).
|
||||
*
|
||||
* A refused login on its own deletes nothing. The account signs out and its data
|
||||
* hides until someone signs in with the new password, or for 30 days
|
||||
* A refused login or an idle or session limit on its own deletes nothing. The
|
||||
* account signs out and its data hides until the next sign-in, or for 30 days
|
||||
* (EVICTED_KEEP_MS). What this file keeps for it is a number, for the sign-in page.
|
||||
* The usual cause is a password changed on another device, and deleting there
|
||||
* would lose unsent mail for very little protection against a thief. A deletion
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { onCredentialsRejected } from './credentials-rejected';
|
||||
import { onCredentialsRejected, type ForcedSignOutKind } from './credentials-rejected';
|
||||
import { forgetEvictedSecrets } from './evicted-secrets';
|
||||
import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode';
|
||||
import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state';
|
||||
@@ -385,7 +385,7 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc
|
||||
tellOtherWindows(account, 'start');
|
||||
registerOfflineHooks();
|
||||
|
||||
const ownOnly = reason === 'rejected';
|
||||
const ownOnly = reason === 'rejected' || reason === 'session';
|
||||
const unsent = await unsentForWipe(account);
|
||||
if (record && !readWipeConfirmations().some((entry) => entry.id === record.id)) {
|
||||
queueWipeConfirmation({ account, id: record.id, secret: record.secret, reason, unsent });
|
||||
@@ -604,14 +604,16 @@ async function countRefused(account: string): Promise<void> {
|
||||
setRefusedCount(account, await unsentForWipe(account));
|
||||
}
|
||||
|
||||
const wipeReasonFor = (kind: ForcedSignOutKind | undefined): WipeReason => (kind === 'session' ? 'session' : 'rejected');
|
||||
|
||||
/**
|
||||
* The login was refused, and the auth store is signing it out and hiding the data.
|
||||
* It goes now only if the organisation asked for that, and only for a refusal that
|
||||
* is one for certain: a single bad answer at a start can be a server in trouble.
|
||||
* The login ended with nobody asking, and the auth store is signing it out and
|
||||
* hiding the data. It goes now only if the organisation asked for that, and only
|
||||
* when it's certain: a single bad answer at a start can be a server in trouble.
|
||||
*/
|
||||
async function heardRefusal(account: string, certain: boolean): Promise<void> {
|
||||
if (certain && policyKnown() && devicesForAccount(account).wipeOnRefusedLogin) {
|
||||
await wipeAccountFromDevice(account, 'rejected');
|
||||
async function heardRefusal(account: string, certain: boolean, kind: ForcedSignOutKind): Promise<void> {
|
||||
if (certain && policyKnown() && devicesForAccount(account).wipeOnForcedSignOut) {
|
||||
await wipeAccountFromDevice(account, wipeReasonFor(kind));
|
||||
return;
|
||||
}
|
||||
await countRefused(account);
|
||||
@@ -625,8 +627,8 @@ export async function sweepRefusedLogins(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
// Nobody's signed in to it, so no groups to go by: what applies to everyone reaches it.
|
||||
if (wait.certain && policyKnown() && devicesForNoOne().wipeOnRefusedLogin) {
|
||||
await wipeAccountFromDevice(account, 'rejected');
|
||||
if (wait.certain && policyKnown() && devicesForNoOne().wipeOnForcedSignOut) {
|
||||
await wipeAccountFromDevice(account, wipeReasonFor(wait.kind));
|
||||
continue;
|
||||
}
|
||||
if (wait.unsent === null) await countRefused(account);
|
||||
@@ -639,8 +641,8 @@ let registered = false;
|
||||
export function registerDeviceWipe(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
onCredentialsRejected((account, certain) => {
|
||||
void heardRefusal(account, certain);
|
||||
onCredentialsRejected((account, certain, kind) => {
|
||||
void heardRefusal(account, certain, kind);
|
||||
});
|
||||
onBeforeSignOut(async (accountId, { keepLocalStore }) => {
|
||||
if (!keepLocalStore) await dropRows(accountId);
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.",
|
||||
"refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.",
|
||||
"confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.",
|
||||
"confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز"
|
||||
"confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز",
|
||||
"session_waiting": "رسائل لم تُرسل تنتظر تسجيل دخولك التالي: {count}. تبقى حتى {date}.",
|
||||
"session_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر تسجيل دخولك التالي. تبقى حتى {date}.",
|
||||
"needs_sign_in": "يلزم تسجيل الدخول",
|
||||
"switcher_waiting": "رسائل لم تُرسل: {count}. ستُحذف من هذا الجهاز في {date}.",
|
||||
"switcher_kept": "سيُحذف البريد الموجود على هذا الجهاز في {date}.",
|
||||
"sibling_waiting": "تم تسجيل خروج {name}. رسائل لم تُرسل: {count}. سجّل الدخول قبل {date} وإلا فستُحذف من هذا الجهاز.",
|
||||
"sibling_kept": "تم تسجيل خروج {name}. سجّل الدخول قبل {date} وإلا فسيُحذف البريد من هذا الجهاز."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Missatges sense enviar que esperen la teva contrasenya nova: {count}. Es conserven fins al {date}.",
|
||||
"refused_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant la teva contrasenya nova. Es conserven fins al {date}.",
|
||||
"confirm_unsent_unknown": "És possible que hi hagi missatges sense enviar esperant aquí. Es perdran.",
|
||||
"confirm_templates": "Plantilles de correu desades en aquest dispositiu"
|
||||
"confirm_templates": "Plantilles de correu desades en aquest dispositiu",
|
||||
"session_waiting": "Missatges sense enviar que esperen el teu proper inici de sessió: {count}. Es conserven fins al {date}.",
|
||||
"session_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant el teu proper inici de sessió. Es conserven fins al {date}.",
|
||||
"needs_sign_in": "Cal iniciar la sessió",
|
||||
"switcher_waiting": "Missatges sense enviar: {count}. S'eliminaran d'aquest dispositiu el {date}.",
|
||||
"switcher_kept": "El correu d'aquest dispositiu s'eliminarà el {date}.",
|
||||
"sibling_waiting": "S'ha tancat la sessió de {name}. Missatges sense enviar: {count}. Inicia la sessió abans del {date} o s'eliminaran d'aquest dispositiu.",
|
||||
"sibling_kept": "S'ha tancat la sessió de {name}. Inicia la sessió abans del {date} o el correu s'eliminarà d'aquest dispositiu."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Neodeslané zprávy čekající na nové heslo: {count}. Zůstanou do {date}.",
|
||||
"refused_waiting_unknown": "Na nové heslo možná čekají neodeslané zprávy. Zůstanou do {date}.",
|
||||
"confirm_unsent_unknown": "Možná tu čekají neodeslané zprávy. Budou ztraceny.",
|
||||
"confirm_templates": "Šablony e-mailů uložené v tomto zařízení"
|
||||
"confirm_templates": "Šablony e-mailů uložené v tomto zařízení",
|
||||
"session_waiting": "Neodeslané zprávy čekající na další přihlášení: {count}. Zůstanou do {date}.",
|
||||
"session_waiting_unknown": "Na další přihlášení možná čekají neodeslané zprávy. Zůstanou do {date}.",
|
||||
"needs_sign_in": "Vyžaduje přihlášení",
|
||||
"switcher_waiting": "Neodeslané zprávy: {count}. Budou smazány z tohoto zařízení {date}.",
|
||||
"switcher_kept": "Pošta v tomto zařízení bude smazána {date}.",
|
||||
"sibling_waiting": "Účet {name} byl odhlášen. Neodeslané zprávy: {count}. Přihlaste se do {date}, jinak budou z tohoto zařízení smazány.",
|
||||
"sibling_kept": "Účet {name} byl odhlášen. Přihlaste se do {date}, jinak bude pošta z tohoto zařízení smazána."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Usendte beskeder, der venter på din nye adgangskode: {count}. De bliver til {date}.",
|
||||
"refused_waiting_unknown": "Usendte beskeder venter måske på din nye adgangskode. De bliver til {date}.",
|
||||
"confirm_unsent_unknown": "Der venter måske usendte beskeder her. De går tabt.",
|
||||
"confirm_templates": "E-mailskabeloner gemt på denne enhed"
|
||||
"confirm_templates": "E-mailskabeloner gemt på denne enhed",
|
||||
"session_waiting": "Usendte beskeder, der venter på din næste login: {count}. De bliver til {date}.",
|
||||
"session_waiting_unknown": "Usendte beskeder venter måske på din næste login. De bliver til {date}.",
|
||||
"needs_sign_in": "Kræver login",
|
||||
"switcher_waiting": "Usendte beskeder: {count}. Slettes fra denne enhed den {date}.",
|
||||
"switcher_kept": "Mailen på denne enhed slettes den {date}.",
|
||||
"sibling_waiting": "{name} blev logget ud. Usendte beskeder: {count}. Log ind senest {date}, ellers slettes de fra denne enhed.",
|
||||
"sibling_kept": "{name} blev logget ud. Log ind senest {date}, ellers slettes mailen fra denne enhed."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Auf Ihr neues Passwort warten ungesendete Nachrichten: {count}. Sie bleiben bis zum {date}.",
|
||||
"refused_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihr neues Passwort. Sie bleiben bis zum {date}.",
|
||||
"confirm_unsent_unknown": "Hier warten möglicherweise ungesendete Nachrichten. Sie gehen verloren.",
|
||||
"confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen"
|
||||
"confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen",
|
||||
"session_waiting": "Ungesendete Nachrichten, die auf Ihre nächste Anmeldung warten: {count}. Sie bleiben bis zum {date}.",
|
||||
"session_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihre nächste Anmeldung. Sie bleiben bis zum {date}.",
|
||||
"needs_sign_in": "Anmeldung nötig",
|
||||
"switcher_waiting": "Ungesendete Nachrichten: {count}. Werden am {date} von diesem Gerät gelöscht.",
|
||||
"switcher_kept": "Die E-Mails auf diesem Gerät werden am {date} gelöscht.",
|
||||
"sibling_waiting": "{name} wurde abgemeldet. Ungesendete Nachrichten: {count}. Melden Sie sich bis zum {date} an, sonst werden sie von diesem Gerät gelöscht.",
|
||||
"sibling_kept": "{name} wurde abgemeldet. Melden Sie sich bis zum {date} an, sonst werden die E-Mails von diesem Gerät gelöscht."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for your new password, until {date}.",
|
||||
"refused_waiting_unknown": "Unsent messages may be waiting for your new password, until {date}.",
|
||||
"confirm_unsent_unknown": "Unsent messages may be waiting here. They will be lost.",
|
||||
"confirm_templates": "Email templates saved on this device"
|
||||
"confirm_templates": "Email templates saved on this device",
|
||||
"session_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for you to sign in again, until {date}.",
|
||||
"session_waiting_unknown": "Unsent messages may be waiting for you to sign in again, until {date}.",
|
||||
"needs_sign_in": "Needs sign-in",
|
||||
"switcher_waiting": "Unsent messages: {count}. Deleted from this device on {date}.",
|
||||
"switcher_kept": "Its mail on this device is deleted on {date}.",
|
||||
"sibling_waiting": "{name} was signed out. Unsent messages: {count}. Sign in by {date} or they're deleted from this device.",
|
||||
"sibling_kept": "{name} was signed out. Sign in by {date} or its mail is deleted from this device."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Mensajes sin enviar que esperan tu nueva contraseña: {count}. Se conservan hasta el {date}.",
|
||||
"refused_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu nueva contraseña. Se conservan hasta el {date}.",
|
||||
"confirm_unsent_unknown": "Puede que haya mensajes sin enviar esperando aquí. Se perderán.",
|
||||
"confirm_templates": "Plantillas de correo guardadas en este dispositivo"
|
||||
"confirm_templates": "Plantillas de correo guardadas en este dispositivo",
|
||||
"session_waiting": "Mensajes sin enviar que esperan tu próximo inicio de sesión: {count}. Se conservan hasta el {date}.",
|
||||
"session_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu próximo inicio de sesión. Se conservan hasta el {date}.",
|
||||
"needs_sign_in": "Requiere iniciar sesión",
|
||||
"switcher_waiting": "Mensajes sin enviar: {count}. Se eliminarán de este dispositivo el {date}.",
|
||||
"switcher_kept": "Su correo en este dispositivo se eliminará el {date}.",
|
||||
"sibling_waiting": "Se ha cerrado la sesión de {name}. Mensajes sin enviar: {count}. Inicia sesión antes del {date} o se eliminarán de este dispositivo.",
|
||||
"sibling_kept": "Se ha cerrado la sesión de {name}. Inicia sesión antes del {date} o su correo se eliminará de este dispositivo."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Saatmata sõnumid ootavad teie uut parooli: {count}. Need jäävad alles kuni {date}.",
|
||||
"refused_waiting_unknown": "Saatmata sõnumid võivad oodata teie uut parooli. Need jäävad alles kuni {date}.",
|
||||
"confirm_unsent_unknown": "Siin võivad oodata saatmata sõnumid. Need lähevad kaduma.",
|
||||
"confirm_templates": "Selles seadmes salvestatud e-kirjade mallid"
|
||||
"confirm_templates": "Selles seadmes salvestatud e-kirjade mallid",
|
||||
"session_waiting": "Saatmata sõnumid ootavad järgmist sisselogimist: {count}. Need jäävad alles kuni {date}.",
|
||||
"session_waiting_unknown": "Saatmata sõnumid võivad oodata järgmist sisselogimist. Need jäävad alles kuni {date}.",
|
||||
"needs_sign_in": "Vajab sisselogimist",
|
||||
"switcher_waiting": "Saatmata sõnumid: {count}. Kustutatakse sellest seadmest {date}.",
|
||||
"switcher_kept": "Selle seadme e-kirjad kustutatakse {date}.",
|
||||
"sibling_waiting": "{name} logiti välja. Saatmata sõnumid: {count}. Logige sisse hiljemalt {date}, muidu kustutatakse need sellest seadmest.",
|
||||
"sibling_kept": "{name} logiti välja. Logige sisse hiljemalt {date}, muidu kustutatakse e-kirjad sellest seadmest."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "پیامهای ارسالنشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته میشوند.",
|
||||
"refused_waiting_unknown": "شاید پیامهای ارسالنشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته میشوند.",
|
||||
"confirm_unsent_unknown": "شاید پیامهای ارسالنشده اینجا منتظر باشند. از بین خواهند رفت.",
|
||||
"confirm_templates": "الگوهای ایمیل ذخیرهشده در این دستگاه"
|
||||
"confirm_templates": "الگوهای ایمیل ذخیرهشده در این دستگاه",
|
||||
"session_waiting": "پیامهای ارسالنشده در انتظار ورود بعدی شما: {count}. تا {date} نگه داشته میشوند.",
|
||||
"session_waiting_unknown": "شاید پیامهای ارسالنشده در انتظار ورود بعدی شما باشند. تا {date} نگه داشته میشوند.",
|
||||
"needs_sign_in": "نیاز به ورود دارد",
|
||||
"switcher_waiting": "پیامهای ارسالنشده: {count}. در {date} از این دستگاه حذف میشوند.",
|
||||
"switcher_kept": "ایمیلهای این دستگاه در {date} حذف میشوند.",
|
||||
"sibling_waiting": "{name} از حساب خارج شد. پیامهای ارسالنشده: {count}. تا {date} وارد شوید، وگرنه از این دستگاه حذف میشوند.",
|
||||
"sibling_kept": "{name} از حساب خارج شد. تا {date} وارد شوید، وگرنه ایمیلها از این دستگاه حذف میشوند."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "ایمیلها، تقویم، مخاطبان و فایلهایی را که انتخاب میکنید بهصورت رمزگذاریشده در این دستگاه نگه میدارد. در حالت آفلاین میتوانید ایمیلها را بخوانید، پرچم بزنید و جابهجا کنید، و آنچه مینویسید در صندوق خروجی منتظر میماند. با خروج از حساب همه پاک میشود.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Messages non envoyés qui attendent votre nouveau mot de passe : {count}. Ils restent jusqu'au {date}.",
|
||||
"refused_waiting_unknown": "Des messages non envoyés attendent peut-être votre nouveau mot de passe. Ils restent jusqu'au {date}.",
|
||||
"confirm_unsent_unknown": "Des messages non envoyés attendent peut-être ici. Ils seront perdus.",
|
||||
"confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil"
|
||||
"confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil",
|
||||
"session_waiting": "Messages non envoyés qui attendent votre prochaine connexion : {count}. Ils restent jusqu'au {date}.",
|
||||
"session_waiting_unknown": "Des messages non envoyés attendent peut-être votre prochaine connexion. Ils restent jusqu'au {date}.",
|
||||
"needs_sign_in": "Connexion requise",
|
||||
"switcher_waiting": "Messages non envoyés : {count}. Supprimés de cet appareil le {date}.",
|
||||
"switcher_kept": "Ses e-mails sur cet appareil seront supprimés le {date}.",
|
||||
"sibling_waiting": "{name} a été déconnecté. Messages non envoyés : {count}. Connectez-vous avant le {date}, sinon ils seront supprimés de cet appareil.",
|
||||
"sibling_kept": "{name} a été déconnecté. Connectez-vous avant le {date}, sinon ses e-mails seront supprimés de cet appareil."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.",
|
||||
|
||||
@@ -5784,7 +5784,14 @@
|
||||
"refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.",
|
||||
"refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.",
|
||||
"confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.",
|
||||
"confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה"
|
||||
"confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה",
|
||||
"session_waiting": "הודעות שלא נשלחו ממתינות לכניסה הבאה שלך: {count}. הן יישמרו עד {date}.",
|
||||
"session_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לכניסה הבאה שלך. הן יישמרו עד {date}.",
|
||||
"needs_sign_in": "נדרשת כניסה",
|
||||
"switcher_waiting": "הודעות שלא נשלחו: {count}. יימחקו מהמכשיר הזה ב-{date}.",
|
||||
"switcher_kept": "הדואר במכשיר הזה יימחק ב-{date}.",
|
||||
"sibling_waiting": "{name} נותק. הודעות שלא נשלחו: {count}. יש להיכנס עד {date}, אחרת הן יימחקו מהמכשיר הזה.",
|
||||
"sibling_kept": "{name} נותק. יש להיכנס עד {date}, אחרת הדואר יימחק מהמכשיר הזה."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Az új jelszóra váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.",
|
||||
"refused_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak az új jelszóra. Eddig maradnak: {date}.",
|
||||
"confirm_unsent_unknown": "Lehet, hogy el nem küldött üzenetek várakoznak itt. Elvesznek.",
|
||||
"confirm_templates": "Ezen az eszközön mentett e-mail-sablonok"
|
||||
"confirm_templates": "Ezen az eszközön mentett e-mail-sablonok",
|
||||
"session_waiting": "A következő bejelentkezésre váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.",
|
||||
"session_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak a következő bejelentkezésre. Eddig maradnak: {date}.",
|
||||
"needs_sign_in": "Bejelentkezés szükséges",
|
||||
"switcher_waiting": "El nem küldött üzenetek: {count}. Törlés erről az eszközről: {date}.",
|
||||
"switcher_kept": "Az eszközön lévő levelek törlése: {date}.",
|
||||
"sibling_waiting": "{name} kijelentkezett. El nem küldött üzenetek: {count}. Jelentkezzen be eddig: {date}, különben törlődnek erről az eszközről.",
|
||||
"sibling_kept": "{name} kijelentkezett. Jelentkezzen be eddig: {date}, különben a levelek törlődnek erről az eszközről."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Messaggi non inviati in attesa della nuova password: {count}. Restano fino al {date}.",
|
||||
"refused_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa della nuova password. Restano fino al {date}.",
|
||||
"confirm_unsent_unknown": "Qui potrebbero esserci messaggi non inviati in attesa. Andranno persi.",
|
||||
"confirm_templates": "Modelli di email salvati su questo dispositivo"
|
||||
"confirm_templates": "Modelli di email salvati su questo dispositivo",
|
||||
"session_waiting": "Messaggi non inviati in attesa del prossimo accesso: {count}. Restano fino al {date}.",
|
||||
"session_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa del prossimo accesso. Restano fino al {date}.",
|
||||
"needs_sign_in": "Accesso richiesto",
|
||||
"switcher_waiting": "Messaggi non inviati: {count}. Eliminati da questo dispositivo il {date}.",
|
||||
"switcher_kept": "Le sue email su questo dispositivo verranno eliminate il {date}.",
|
||||
"sibling_waiting": "{name} è stato disconnesso. Messaggi non inviati: {count}. Accedi entro il {date} o verranno eliminati da questo dispositivo.",
|
||||
"sibling_kept": "{name} è stato disconnesso. Accedi entro il {date} o le sue email verranno eliminate da questo dispositivo."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。",
|
||||
"refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。",
|
||||
"confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。",
|
||||
"confirm_templates": "このデバイスに保存されたメールテンプレート"
|
||||
"confirm_templates": "このデバイスに保存されたメールテンプレート",
|
||||
"session_waiting": "次回のサインインを待っている未送信メッセージ: {count} 件。{date} まで保持されます。",
|
||||
"session_waiting_unknown": "未送信のメッセージが次回のサインインを待っている可能性があります。{date} まで保持されます。",
|
||||
"needs_sign_in": "サインインが必要",
|
||||
"switcher_waiting": "未送信メッセージ: {count} 件。{date} にこのデバイスから削除されます。",
|
||||
"switcher_kept": "このデバイス上のメールは {date} に削除されます。",
|
||||
"sibling_waiting": "{name} はサインアウトされました。未送信メッセージ: {count} 件。{date} までにサインインしないと、このデバイスから削除されます。",
|
||||
"sibling_kept": "{name} はサインアウトされました。{date} までにサインインしないと、メールはこのデバイスから削除されます。"
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.",
|
||||
"refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.",
|
||||
"confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.",
|
||||
"confirm_templates": "이 기기에 저장된 이메일 템플릿"
|
||||
"confirm_templates": "이 기기에 저장된 이메일 템플릿",
|
||||
"session_waiting": "다음 로그인을 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.",
|
||||
"session_waiting_unknown": "보내지 않은 메시지가 다음 로그인을 기다리고 있을 수 있습니다. {date}까지 보관됩니다.",
|
||||
"needs_sign_in": "로그인 필요",
|
||||
"switcher_waiting": "보내지 않은 메시지: {count}개. {date}에 이 기기에서 삭제됩니다.",
|
||||
"switcher_kept": "이 기기의 메일은 {date}에 삭제됩니다.",
|
||||
"sibling_waiting": "{name} 계정이 로그아웃되었습니다. 보내지 않은 메시지: {count}개. {date}까지 로그인하지 않으면 이 기기에서 삭제됩니다.",
|
||||
"sibling_kept": "{name} 계정이 로그아웃되었습니다. {date}까지 로그인하지 않으면 메일이 이 기기에서 삭제됩니다."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Nenosūtītie ziņojumi gaida jūsu jauno paroli: {count}. Tie paliek līdz {date}.",
|
||||
"refused_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida jūsu jauno paroli. Tie paliek līdz {date}.",
|
||||
"confirm_unsent_unknown": "Šeit var būt nenosūtīti ziņojumi. Tie tiks zaudēti.",
|
||||
"confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes"
|
||||
"confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes",
|
||||
"session_waiting": "Nenosūtītie ziņojumi gaida nākamo pieteikšanos: {count}. Tie paliek līdz {date}.",
|
||||
"session_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida nākamo pieteikšanos. Tie paliek līdz {date}.",
|
||||
"needs_sign_in": "Nepieciešama pieteikšanās",
|
||||
"switcher_waiting": "Nenosūtītie ziņojumi: {count}. Tiks izdzēsti no šīs ierīces {date}.",
|
||||
"switcher_kept": "E-pasts šajā ierīcē tiks izdzēsts {date}.",
|
||||
"sibling_waiting": "{name} tika izrakstīts. Nenosūtītie ziņojumi: {count}. Piesakieties līdz {date}, citādi tie tiks izdzēsti no šīs ierīces.",
|
||||
"sibling_kept": "{name} tika izrakstīts. Piesakieties līdz {date}, citādi e-pasts tiks izdzēsts no šīs ierīces."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.",
|
||||
"refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.",
|
||||
"confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.",
|
||||
"confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд"
|
||||
"confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд",
|
||||
"session_waiting": "Дараагийн нэвтрэлтийг хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.",
|
||||
"session_waiting_unknown": "Илгээгээгүй зурвасууд дараагийн нэвтрэлтийг хүлээж байж магадгүй. {date} хүртэл хадгалагдана.",
|
||||
"needs_sign_in": "Нэвтрэх шаардлагатай",
|
||||
"switcher_waiting": "Илгээгээгүй зурвас: {count}. {date}-нд энэ төхөөрөмжөөс устгагдана.",
|
||||
"switcher_kept": "Энэ төхөөрөмж дээрх имэйл {date}-нд устгагдана.",
|
||||
"sibling_waiting": "{name} системээс гарсан. Илгээгээгүй зурвас: {count}. {date} хүртэл нэвтэрнэ үү, эс бөгөөс энэ төхөөрөмжөөс устгагдана.",
|
||||
"sibling_kept": "{name} системээс гарсан. {date} хүртэл нэвтэрнэ үү, эс бөгөөс имэйл энэ төхөөрөмжөөс устгагдана."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Usendte meldinger som venter på det nye passordet: {count}. De blir til {date}.",
|
||||
"refused_waiting_unknown": "Usendte meldinger venter kanskje på det nye passordet. De blir til {date}.",
|
||||
"confirm_unsent_unknown": "Det venter kanskje usendte meldinger her. De går tapt.",
|
||||
"confirm_templates": "E-postmaler lagret på denne enheten"
|
||||
"confirm_templates": "E-postmaler lagret på denne enheten",
|
||||
"session_waiting": "Usendte meldinger som venter på neste pålogging: {count}. De blir til {date}.",
|
||||
"session_waiting_unknown": "Usendte meldinger venter kanskje på neste pålogging. De blir til {date}.",
|
||||
"needs_sign_in": "Krever pålogging",
|
||||
"switcher_waiting": "Usendte meldinger: {count}. Slettes fra denne enheten {date}.",
|
||||
"switcher_kept": "E-posten på denne enheten slettes {date}.",
|
||||
"sibling_waiting": "{name} ble logget ut. Usendte meldinger: {count}. Logg på innen {date}, ellers slettes de fra denne enheten.",
|
||||
"sibling_kept": "{name} ble logget ut. Logg på innen {date}, ellers slettes e-posten fra denne enheten."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Niet-verzonden berichten die op uw nieuwe wachtwoord wachten: {count}. Ze blijven tot {date}.",
|
||||
"refused_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw nieuwe wachtwoord. Ze blijven tot {date}.",
|
||||
"confirm_unsent_unknown": "Hier wachten mogelijk niet-verzonden berichten. Ze gaan verloren.",
|
||||
"confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen"
|
||||
"confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen",
|
||||
"session_waiting": "Niet-verzonden berichten die op uw volgende aanmelding wachten: {count}. Ze blijven tot {date}.",
|
||||
"session_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw volgende aanmelding. Ze blijven tot {date}.",
|
||||
"needs_sign_in": "Aanmelden nodig",
|
||||
"switcher_waiting": "Niet-verzonden berichten: {count}. Op {date} van dit apparaat verwijderd.",
|
||||
"switcher_kept": "De e-mail op dit apparaat wordt op {date} verwijderd.",
|
||||
"sibling_waiting": "{name} is afgemeld. Niet-verzonden berichten: {count}. Meld u uiterlijk {date} aan, anders worden ze van dit apparaat verwijderd.",
|
||||
"sibling_kept": "{name} is afgemeld. Meld u uiterlijk {date} aan, anders wordt de e-mail van dit apparaat verwijderd."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Niewysłane wiadomości czekające na nowe hasło: {count}. Pozostaną do {date}.",
|
||||
"refused_waiting_unknown": "Niewysłane wiadomości mogą czekać na nowe hasło. Pozostaną do {date}.",
|
||||
"confirm_unsent_unknown": "Tutaj mogą czekać niewysłane wiadomości. Zostaną utracone.",
|
||||
"confirm_templates": "Szablony e-maili zapisane na tym urządzeniu"
|
||||
"confirm_templates": "Szablony e-maili zapisane na tym urządzeniu",
|
||||
"session_waiting": "Niewysłane wiadomości czekające na następne logowanie: {count}. Pozostaną do {date}.",
|
||||
"session_waiting_unknown": "Niewysłane wiadomości mogą czekać na następne logowanie. Pozostaną do {date}.",
|
||||
"needs_sign_in": "Wymaga zalogowania",
|
||||
"switcher_waiting": "Niewysłane wiadomości: {count}. Zostaną usunięte z tego urządzenia {date}.",
|
||||
"switcher_kept": "Poczta na tym urządzeniu zostanie usunięta {date}.",
|
||||
"sibling_waiting": "Konto {name} zostało wylogowane. Niewysłane wiadomości: {count}. Zaloguj się do {date}, inaczej zostaną usunięte z tego urządzenia.",
|
||||
"sibling_kept": "Konto {name} zostało wylogowane. Zaloguj się do {date}, inaczej poczta zostanie usunięta z tego urządzenia."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Mensagens por enviar à espera da nova palavra-passe: {count}. Ficam até {date}.",
|
||||
"refused_waiting_unknown": "Podem existir mensagens por enviar à espera da nova palavra-passe. Ficam até {date}.",
|
||||
"confirm_unsent_unknown": "Podem existir mensagens por enviar aqui. Serão perdidas.",
|
||||
"confirm_templates": "Modelos de e-mail guardados neste dispositivo"
|
||||
"confirm_templates": "Modelos de e-mail guardados neste dispositivo",
|
||||
"session_waiting": "Mensagens por enviar à espera do próximo início de sessão: {count}. Ficam até {date}.",
|
||||
"session_waiting_unknown": "Podem existir mensagens por enviar à espera do próximo início de sessão. Ficam até {date}.",
|
||||
"needs_sign_in": "Requer início de sessão",
|
||||
"switcher_waiting": "Mensagens por enviar: {count}. Eliminadas deste dispositivo em {date}.",
|
||||
"switcher_kept": "O e-mail neste dispositivo será eliminado em {date}.",
|
||||
"sibling_waiting": "A sessão de {name} foi terminada. Mensagens por enviar: {count}. Inicie sessão até {date} ou serão eliminadas deste dispositivo.",
|
||||
"sibling_kept": "A sessão de {name} foi terminada. Inicie sessão até {date} ou o e-mail será eliminado deste dispositivo."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Mesaje netrimise care așteaptă parola nouă: {count}. Rămân până la {date}.",
|
||||
"refused_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte parola nouă. Rămân până la {date}.",
|
||||
"confirm_unsent_unknown": "Este posibil să existe aici mesaje netrimise. Se vor pierde.",
|
||||
"confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv"
|
||||
"confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv",
|
||||
"session_waiting": "Mesaje netrimise care așteaptă următoarea conectare: {count}. Rămân până la {date}.",
|
||||
"session_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte următoarea conectare. Rămân până la {date}.",
|
||||
"needs_sign_in": "Necesită conectare",
|
||||
"switcher_waiting": "Mesaje netrimise: {count}. Vor fi șterse de pe acest dispozitiv la {date}.",
|
||||
"switcher_kept": "E-mailurile de pe acest dispozitiv vor fi șterse la {date}.",
|
||||
"sibling_waiting": "{name} a fost deconectat. Mesaje netrimise: {count}. Conectați-vă până la {date}, altfel vor fi șterse de pe acest dispozitiv.",
|
||||
"sibling_kept": "{name} a fost deconectat. Conectați-vă până la {date}, altfel e-mailurile vor fi șterse de pe acest dispozitiv."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.",
|
||||
"refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.",
|
||||
"confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.",
|
||||
"confirm_templates": "Шаблоны писем, сохранённые на этом устройстве"
|
||||
"confirm_templates": "Шаблоны писем, сохранённые на этом устройстве",
|
||||
"session_waiting": "Неотправленные сообщения ждут следующего входа: {count}. Они хранятся до {date}.",
|
||||
"session_waiting_unknown": "Возможно, неотправленные сообщения ждут следующего входа. Они хранятся до {date}.",
|
||||
"needs_sign_in": "Нужен вход",
|
||||
"switcher_waiting": "Неотправленные сообщения: {count}. Будут удалены с этого устройства {date}.",
|
||||
"switcher_kept": "Почта на этом устройстве будет удалена {date}.",
|
||||
"sibling_waiting": "Выполнен выход из {name}. Неотправленные сообщения: {count}. Войдите до {date}, иначе они будут удалены с этого устройства.",
|
||||
"sibling_kept": "Выполнен выход из {name}. Войдите до {date}, иначе почта будет удалена с этого устройства."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Neodoslané správy čakajúce na nové heslo: {count}. Zostanú do {date}.",
|
||||
"refused_waiting_unknown": "Na nové heslo možno čakajú neodoslané správy. Zostanú do {date}.",
|
||||
"confirm_unsent_unknown": "Možno tu čakajú neodoslané správy. Stratia sa.",
|
||||
"confirm_templates": "Šablóny e-mailov uložené v tomto zariadení"
|
||||
"confirm_templates": "Šablóny e-mailov uložené v tomto zariadení",
|
||||
"session_waiting": "Neodoslané správy čakajúce na ďalšie prihlásenie: {count}. Zostanú do {date}.",
|
||||
"session_waiting_unknown": "Na ďalšie prihlásenie možno čakajú neodoslané správy. Zostanú do {date}.",
|
||||
"needs_sign_in": "Vyžaduje prihlásenie",
|
||||
"switcher_waiting": "Neodoslané správy: {count}. Odstránia sa z tohto zariadenia {date}.",
|
||||
"switcher_kept": "Pošta v tomto zariadení sa odstráni {date}.",
|
||||
"sibling_waiting": "Účet {name} bol odhlásený. Neodoslané správy: {count}. Prihláste sa do {date}, inak sa z tohto zariadenia odstránia.",
|
||||
"sibling_kept": "Účet {name} bol odhlásený. Prihláste sa do {date}, inak sa pošta z tohto zariadenia odstráni."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.",
|
||||
|
||||
@@ -2128,7 +2128,14 @@
|
||||
"refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}",
|
||||
"refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}",
|
||||
"confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย",
|
||||
"confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้"
|
||||
"confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้",
|
||||
"session_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอการลงชื่อเข้าใช้ครั้งถัดไป: {count} เก็บไว้ถึง {date}",
|
||||
"session_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอการลงชื่อเข้าใช้ครั้งถัดไป เก็บไว้ถึง {date}",
|
||||
"needs_sign_in": "ต้องลงชื่อเข้าใช้",
|
||||
"switcher_waiting": "ข้อความที่ยังไม่ได้ส่ง: {count} จะถูกลบออกจากอุปกรณ์นี้ในวันที่ {date}",
|
||||
"switcher_kept": "อีเมลในอุปกรณ์นี้จะถูกลบในวันที่ {date}",
|
||||
"sibling_waiting": "{name} ถูกออกจากระบบแล้ว ข้อความที่ยังไม่ได้ส่ง: {count} โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นจะถูกลบออกจากอุปกรณ์นี้",
|
||||
"sibling_kept": "{name} ถูกออกจากระบบแล้ว โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นอีเมลจะถูกลบออกจากอุปกรณ์นี้"
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Yeni parolanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.",
|
||||
"refused_waiting_unknown": "Gönderilmemiş iletiler yeni parolanızı bekliyor olabilir. {date} tarihine kadar kalır.",
|
||||
"confirm_unsent_unknown": "Burada gönderilmemiş iletiler bekliyor olabilir. Kaybolacaklar.",
|
||||
"confirm_templates": "Bu cihazda kayıtlı e-posta şablonları"
|
||||
"confirm_templates": "Bu cihazda kayıtlı e-posta şablonları",
|
||||
"session_waiting": "Bir sonraki oturum açmanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.",
|
||||
"session_waiting_unknown": "Gönderilmemiş iletiler bir sonraki oturum açmanızı bekliyor olabilir. {date} tarihine kadar kalır.",
|
||||
"needs_sign_in": "Oturum açılması gerekiyor",
|
||||
"switcher_waiting": "Gönderilmemiş iletiler: {count}. {date} tarihinde bu cihazdan silinecek.",
|
||||
"switcher_kept": "Bu cihazdaki e-postalar {date} tarihinde silinecek.",
|
||||
"sibling_waiting": "{name} oturumu kapatıldı. Gönderilmemiş iletiler: {count}. {date} tarihine kadar oturum açın, yoksa bu cihazdan silinecekler.",
|
||||
"sibling_kept": "{name} oturumu kapatıldı. {date} tarihine kadar oturum açın, yoksa e-postalar bu cihazdan silinecek."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.",
|
||||
"refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.",
|
||||
"confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.",
|
||||
"confirm_templates": "Шаблони листів, збережені на цьому пристрої"
|
||||
"confirm_templates": "Шаблони листів, збережені на цьому пристрої",
|
||||
"session_waiting": "Ненадіслані повідомлення чекають на наступний вхід: {count}. Вони зберігаються до {date}.",
|
||||
"session_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на наступний вхід. Вони зберігаються до {date}.",
|
||||
"needs_sign_in": "Потрібен вхід",
|
||||
"switcher_waiting": "Ненадіслані повідомлення: {count}. Буде видалено з цього пристрою {date}.",
|
||||
"switcher_kept": "Пошту на цьому пристрої буде видалено {date}.",
|
||||
"sibling_waiting": "Виконано вихід із {name}. Ненадіслані повідомлення: {count}. Увійдіть до {date}, інакше їх буде видалено з цього пристрою.",
|
||||
"sibling_kept": "Виконано вихід із {name}. Увійдіть до {date}, інакше пошту буде видалено з цього пристрою."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.",
|
||||
|
||||
@@ -5795,7 +5795,14 @@
|
||||
"refused_waiting": "Thư chưa gửi đang chờ mật khẩu mới của bạn: {count}. Được giữ đến {date}.",
|
||||
"refused_waiting_unknown": "Có thể có thư chưa gửi đang chờ mật khẩu mới của bạn. Được giữ đến {date}.",
|
||||
"confirm_unsent_unknown": "Có thể có thư chưa gửi đang chờ ở đây. Chúng sẽ bị mất.",
|
||||
"confirm_templates": "Mẫu email đã lưu trên thiết bị này"
|
||||
"confirm_templates": "Mẫu email đã lưu trên thiết bị này",
|
||||
"session_waiting": "Thư chưa gửi đang chờ lần đăng nhập tiếp theo: {count}. Được giữ đến {date}.",
|
||||
"session_waiting_unknown": "Có thể có thư chưa gửi đang chờ lần đăng nhập tiếp theo. Được giữ đến {date}.",
|
||||
"needs_sign_in": "Cần đăng nhập",
|
||||
"switcher_waiting": "Thư chưa gửi: {count}. Sẽ bị xóa khỏi thiết bị này vào {date}.",
|
||||
"switcher_kept": "Email trên thiết bị này sẽ bị xóa vào {date}.",
|
||||
"sibling_waiting": "{name} đã bị đăng xuất. Thư chưa gửi: {count}. Hãy đăng nhập trước {date}, nếu không chúng sẽ bị xóa khỏi thiết bị này.",
|
||||
"sibling_kept": "{name} đã bị đăng xuất. Hãy đăng nhập trước {date}, nếu không email sẽ bị xóa khỏi thiết bị này."
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。",
|
||||
"refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。",
|
||||
"confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。",
|
||||
"confirm_templates": "儲存在此裝置上的郵件範本"
|
||||
"confirm_templates": "儲存在此裝置上的郵件範本",
|
||||
"session_waiting": "等待下次登入的未傳送郵件:{count} 封。保留至 {date}。",
|
||||
"session_waiting_unknown": "可能有未傳送的郵件正在等待下次登入。保留至 {date}。",
|
||||
"needs_sign_in": "需要登入",
|
||||
"switcher_waiting": "未傳送郵件:{count} 封。將於 {date} 從此裝置刪除。",
|
||||
"switcher_kept": "此裝置上的郵件將於 {date} 刪除。",
|
||||
"sibling_waiting": "{name} 已登出。未傳送郵件:{count} 封。請在 {date} 前登入,否則將從此裝置刪除。",
|
||||
"sibling_kept": "{name} 已登出。請在 {date} 前登入,否則郵件將從此裝置刪除。"
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。",
|
||||
|
||||
@@ -5783,7 +5783,14 @@
|
||||
"refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。",
|
||||
"refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。",
|
||||
"confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。",
|
||||
"confirm_templates": "保存在此设备上的邮件模板"
|
||||
"confirm_templates": "保存在此设备上的邮件模板",
|
||||
"session_waiting": "等待下次登录的未发送邮件:{count} 封。保留至 {date}。",
|
||||
"session_waiting_unknown": "可能有未发送的邮件在等待下次登录。保留至 {date}。",
|
||||
"needs_sign_in": "需要登录",
|
||||
"switcher_waiting": "未发送邮件:{count} 封。将于 {date} 从此设备删除。",
|
||||
"switcher_kept": "此设备上的邮件将于 {date} 删除。",
|
||||
"sibling_waiting": "{name} 已退出登录。未发送邮件:{count} 封。请在 {date} 前登录,否则将从此设备删除。",
|
||||
"sibling_kept": "{name} 已退出登录。请在 {date} 前登录,否则邮件将从此设备删除。"
|
||||
},
|
||||
"offlineStorage": {
|
||||
"keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。",
|
||||
|
||||
@@ -51,8 +51,9 @@ vi.mock('@/lib/offline/offline-mode', () => ({
|
||||
|
||||
const heard = vi.hoisted(() => [] as string[]);
|
||||
const certain = vi.hoisted(() => [] as boolean[]);
|
||||
const kinds = vi.hoisted(() => [] as string[]);
|
||||
vi.mock('@/lib/offline/credentials-rejected', () => ({
|
||||
noteCredentialsRejected: (account: string, sure = true) => { heard.push(account); certain.push(sure); },
|
||||
noteCredentialsRejected: (account: string, sure = true, kind = 'refused') => { heard.push(account); certain.push(sure); kinds.push(kind); },
|
||||
onCredentialsRejected: () => () => {},
|
||||
}));
|
||||
|
||||
@@ -97,6 +98,7 @@ beforeEach(async () => {
|
||||
order.length = 0;
|
||||
heard.length = 0;
|
||||
certain.length = 0;
|
||||
kinds.length = 0;
|
||||
clients.made.length = 0;
|
||||
clients.connect = async () => {};
|
||||
gate.run = async () => {};
|
||||
@@ -189,14 +191,15 @@ describe('a login the server ended, at a start', () => {
|
||||
it.each([
|
||||
['a password login', 'basic', '/api/auth/session'],
|
||||
['an OAuth login', 'oauth', '/api/auth/token'],
|
||||
])('the session limit is no refusal, for %s: no hook, no wait, and the account is only dropped', async (_name, mode, route) => {
|
||||
])('the session limit goes the same way, as what it is, for %s: nothing about the password', async (_name, mode, route) => {
|
||||
useOfflineStore.setState({ enabled: [ALICE] });
|
||||
useAccountStore.setState({ accounts: [account(ALICE, 0, mode as 'basic' | 'oauth')] } as never);
|
||||
answer = (url, method) => (url.includes(route) && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null);
|
||||
await useAuthStore.getState().checkAuth();
|
||||
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined());
|
||||
expect(heard).toEqual([]);
|
||||
expect(readRefusedLogins()).toEqual({});
|
||||
expect(heard).toEqual([ALICE]);
|
||||
expect(kinds).toEqual(['session']);
|
||||
expect(readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session', certain: true });
|
||||
expect(isEvicted(ALICE)).toBe(true);
|
||||
expect(useOfflineStore.getState().enabled).toEqual([ALICE]);
|
||||
});
|
||||
@@ -239,11 +242,12 @@ describe('a login the server ended, at a switch', () => {
|
||||
expect(useOfflineStore.getState().enabled).toContain(BOB);
|
||||
});
|
||||
|
||||
it('the session limit doesn\'t fire it and only drops the account', async () => {
|
||||
it('the session limit fires it as what it is, and only drops the account', async () => {
|
||||
useOfflineStore.setState({ enabled: [ALICE, BOB] });
|
||||
answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null);
|
||||
await useAuthStore.getState().switchAccount(BOB);
|
||||
expect(heard).toEqual([]);
|
||||
expect(heard).toEqual([BOB]);
|
||||
expect(kinds).toEqual(['session']);
|
||||
expect(isEvicted(BOB)).toBe(true);
|
||||
expect(useOfflineStore.getState().enabled).toContain(BOB);
|
||||
});
|
||||
@@ -255,6 +259,33 @@ describe('a login the server ended, at a switch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a refusal with another account signed in', () => {
|
||||
beforeEach(() => {
|
||||
useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true });
|
||||
});
|
||||
|
||||
it('writes down who was there, so the note reaches them and nobody later', async () => {
|
||||
useOfflineStore.setState({ enabled: [ALICE, BOB] });
|
||||
answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null);
|
||||
await useAuthStore.getState().refreshAccessToken({ accountId: BOB });
|
||||
expect(readRefusedLogins()[BOB]).toMatchObject({ seenBy: [ALICE], label: BOB, username: 'bob' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a login with no "Remember me", when the browser closed', () => {
|
||||
it('still takes its data with it: that was the choice on a shared computer', async () => {
|
||||
const reasons = await removalReasons();
|
||||
useAccountStore.setState({ accounts: [{ ...account(ALICE, 0, 'basic'), rememberMe: false }], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
useAuthStore.setState({ activeAccountId: ALICE });
|
||||
useOfflineStore.setState({ enabled: [ALICE] });
|
||||
await useAuthStore.getState().checkAuth();
|
||||
await vi.waitFor(() => expect(reasons).toEqual(['explicit']));
|
||||
expect(readRefusedLogins()).toEqual({});
|
||||
expect(heard).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a password the server keeps refusing', () => {
|
||||
beforeEach(() => {
|
||||
useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
|
||||
|
||||
+48
-28
@@ -39,7 +39,7 @@ import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
|
||||
import { withTokenLock } from '@/lib/auth/token-lock';
|
||||
import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel';
|
||||
import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role';
|
||||
import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected';
|
||||
import { noteCredentialsRejected, type ForcedSignOutKind } from '@/lib/offline/credentials-rejected';
|
||||
import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state';
|
||||
import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state';
|
||||
import { toAsciiEmail } from '@/lib/idn';
|
||||
@@ -176,6 +176,8 @@ export interface LogoutOptions {
|
||||
* hidden, for the next sign-in or 30 days.
|
||||
*/
|
||||
keepLocalData?: boolean;
|
||||
/** It's the organisation's idle or session limit that ended it: with `keepLocalData`, what lib/offline/device-wipe.ts is told. */
|
||||
sessionLimit?: boolean;
|
||||
}
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||
@@ -805,18 +807,27 @@ function holdClient(accountId: string, client: JMAPClient): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* The server refused this login, and the data hides from here on. It waits for the
|
||||
* new password, or goes after 30 days, or now if the organisation asks for that
|
||||
* This login ended with nobody asking: the server refused it, or the organisation's
|
||||
* idle or session limit ran out. The data hides from here on. It waits for the next
|
||||
* sign-in, or goes after 30 days, or now if the organisation asks for that
|
||||
* (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). The usual cause is a
|
||||
* password changed on another device: nothing goes by itself, unsent mail included.
|
||||
* `certain`: `invalid_grant`, or a 401 that lasted. Not one bad answer at a connect.
|
||||
* `certain`: not one bad answer at a connect.
|
||||
*/
|
||||
function noteRefusedLogin(accountId: string, certain: boolean): void {
|
||||
function noteRefusedLogin(accountId: string, certain: boolean, kind: ForcedSignOutKind = 'refused'): void {
|
||||
if (useOfflineStore.getState().enabled.includes(accountId)) {
|
||||
markLoginRefused(accountId, certain);
|
||||
const { accounts } = useAccountStore.getState();
|
||||
const account = accounts.find((entry) => entry.id === accountId);
|
||||
markLoginRefused(accountId, {
|
||||
certain,
|
||||
kind,
|
||||
seenBy: accounts.filter((entry) => entry.id !== accountId).map((entry) => entry.id),
|
||||
label: account?.email || account?.username,
|
||||
username: account?.username,
|
||||
});
|
||||
markEvicted(accountId);
|
||||
}
|
||||
noteCredentialsRejected(accountId, certain);
|
||||
noteCredentialsRejected(accountId, certain, kind);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1315,17 +1326,19 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server refused the login itself: invalid_grant. That signs out and
|
||||
* hides the data (noteRefusedLogin). Anything else keeps it the same way with no
|
||||
* word to anyone, including a body we can't read and the organisation's session
|
||||
* limit, which says the session is old and nothing about the password.
|
||||
* What a refused renewal says about the login: the server refused it
|
||||
* (invalid_grant), or the organisation's session limit was reached, which says
|
||||
* nothing about the password. Both sign out and hide the data (noteRefusedLogin).
|
||||
* Anything else keeps it the same way with no word to anyone, including a body
|
||||
* we can't read.
|
||||
*/
|
||||
async function loginRefused(res: Response): Promise<boolean> {
|
||||
async function forcedSignOutOf(res: Response): Promise<ForcedSignOutKind | null> {
|
||||
try {
|
||||
const { error } = await res.clone().json() as { error?: unknown };
|
||||
return error === 'invalid_grant';
|
||||
if (error === 'invalid_grant') return 'refused';
|
||||
return error === SESSION_TOO_OLD ? 'session' : null;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2398,15 +2411,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
// maintenance windows and offline spells.
|
||||
if (res.status === 401) {
|
||||
resetRefreshBackoff(accountId ?? undefined);
|
||||
const refused = await loginRefused(res);
|
||||
const forced = await forcedSignOutOf(res);
|
||||
// Only this login's refresh token was refused.
|
||||
if (accountId && !isActive) {
|
||||
if (refused) noteRefusedLogin(accountId, true);
|
||||
if (forced) noteRefusedLogin(accountId, true, forced);
|
||||
get().removeAccount(accountId, 'evicted');
|
||||
return null;
|
||||
}
|
||||
await saveDraftsBeforeExpiry(slot);
|
||||
if (refused && accountId) noteRefusedLogin(accountId, true);
|
||||
if (forced && accountId) noteRefusedLogin(accountId, true, forced);
|
||||
notifyParent('sso:session-expired');
|
||||
markSessionExpired();
|
||||
get().logout({ endProviderSession: false, keepLocalData: true });
|
||||
@@ -2669,7 +2682,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
logoutAll: async (options) => {
|
||||
if (options?.endProviderSession !== false && !(await passSignOutGates('all'))) return;
|
||||
await beforeLogoutHooks('all');
|
||||
const keepLocalData = options?.keepLocalData === true;
|
||||
// Before anything goes, while the accounts are still there to be named.
|
||||
if (options?.sessionLimit) {
|
||||
for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session');
|
||||
}
|
||||
await beforeLogoutHooks('all', { keepLocalStore: keepLocalData });
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
const allAccounts = [...accountStore.accounts];
|
||||
@@ -2696,11 +2714,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Credentials are cleared before the page state - see logout().
|
||||
const endSessionUrl = await cleanup;
|
||||
|
||||
performFullLogout(set);
|
||||
performFullLogout(set, { keepLocalStore: keepLocalData });
|
||||
|
||||
// Clear all accounts from registry
|
||||
for (const account of allAccounts) {
|
||||
accountStore.removeAccount(account.id);
|
||||
accountStore.removeAccount(account.id, keepLocalData ? 'evicted' : 'explicit');
|
||||
}
|
||||
|
||||
clearKeystoreGrants();
|
||||
@@ -2739,7 +2757,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
let targetClient = clients.get(accountId);
|
||||
const wasConnected = !!targetClient;
|
||||
let targetRestoreRateLimited = false;
|
||||
let targetRefused = false;
|
||||
let targetForced: ForcedSignOutKind | null = null;
|
||||
|
||||
if (!targetClient) {
|
||||
// Null out the client immediately so the page doesn't fire data-loading
|
||||
@@ -2756,7 +2774,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
try {
|
||||
if (targetAccount.authMode === 'oauth') {
|
||||
const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe });
|
||||
if (!res.ok && res.status === 401) targetRefused = await loginRefused(res);
|
||||
if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res);
|
||||
if (res.ok) {
|
||||
const { access_token, expires_in } = await res.json();
|
||||
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId }));
|
||||
@@ -2773,7 +2791,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
} else if (targetAccount.rememberMe) {
|
||||
const res = await fetchSlotSession(targetAccount.cookieSlot);
|
||||
if (!res.ok && res.status === 401) targetRefused = await loginRefused(res);
|
||||
if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res);
|
||||
if (res.ok) {
|
||||
const session: RememberedSession = await res.json();
|
||||
targetClient = clientForSession(session);
|
||||
@@ -2825,7 +2843,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
|
||||
// Cannot restore - remove the stale account and redirect to login
|
||||
if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused);
|
||||
if (targetForced) noteRefusedLogin(accountId, true, targetForced);
|
||||
else if (refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, false);
|
||||
refusedAtConnect.delete(accountId);
|
||||
evictAccount(accountId);
|
||||
accountStore.removeAccount(accountId, 'evicted');
|
||||
@@ -3086,7 +3105,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Repeated 500/502 (see recordPermanentRefreshFailure) falls
|
||||
// through here and evicts the account like a rejection. (#972)
|
||||
if (res.status >= 500) notifySignInAgain();
|
||||
throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { refused: await loginRefused(res) });
|
||||
throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { forced: await forcedSignOutOf(res) });
|
||||
}
|
||||
} else {
|
||||
const res = await fetchSlotSession(account.cookieSlot);
|
||||
@@ -3107,7 +3126,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
} else if (res.status >= 500 || res.status === 429) {
|
||||
throw new TransientAuthError('Session restore failed', res.status);
|
||||
} else {
|
||||
throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) });
|
||||
throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { forced: await forcedSignOutOf(res) });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3145,8 +3164,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
// again rather than seeing a stale error entry forever. Its
|
||||
// refresh token goes with it: nothing would use or revoke it. Its
|
||||
// local data waits.
|
||||
const refused = (err as { refused?: boolean }).refused === true;
|
||||
if (refused || refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, refused);
|
||||
const { forced } = err as { forced?: ForcedSignOutKind | null };
|
||||
if (forced) noteRefusedLogin(account.id, true, forced);
|
||||
else if (refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, false);
|
||||
refusedAtConnect.delete(account.id);
|
||||
evictAccount(account.id);
|
||||
accountStore.removeAccount(account.id, 'evicted');
|
||||
|
||||
Reference in New Issue
Block a user