Merge pull request #123 from LINUXexpert-org/untrusted-device-mode
Ask whose computer this is, and believe the answer
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { startIdleLogout, stopIdleLogout, IDLE_TIMEOUT_MS } from "@/lib/idleLogout";
|
||||
|
||||
describe("idle sign-out on an untrusted device", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
stopIdleLogout();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("signs out after five minutes of nothing happening", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
expect(IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the clock again on any sign of a person", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - 1000);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires once, not repeatedly, and stops listening afterwards", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 3);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A late event must not resurrect a timer for a session that has ended.
|
||||
window.dispatchEvent(new Event("keydown"));
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops cleanly, so a trusted sign-in is never signed out", () => {
|
||||
const expire = vi.fn();
|
||||
startIdleLogout(expire);
|
||||
stopIdleLogout();
|
||||
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
accountKey,
|
||||
clearAllData,
|
||||
clearSignedInData,
|
||||
isDeviceTrusted,
|
||||
loadJson,
|
||||
loadRaw,
|
||||
saveJson,
|
||||
setDeviceTrusted,
|
||||
} from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* The gate is a privacy boundary rather than a convenience, so it is tested
|
||||
* from both sides: that a trusted device still works exactly as it did, and
|
||||
* that an untrusted one leaves nothing to find.
|
||||
*/
|
||||
describe("device-trusted storage", () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
key: (i: number) => [...store.keys()][i] ?? null,
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
setDeviceTrusted(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
|
||||
it("writes nothing at all when the device is not trusted", () => {
|
||||
saveJson("settings", { theme: "dark" });
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
expect([...store.keys()].filter((k) => k !== "ihasmail:deviceTrusted")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not read residue left by an earlier trusted session", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
setDeviceTrusted(false);
|
||||
// The bytes are still on disk until a purge; the gate must not serve them.
|
||||
expect(loadRaw(accountKey("acct1", "recent"), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips normally on a trusted device", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
expect(loadJson("settings", { theme: "light", accent: "blue" })).toEqual({ theme: "dark", accent: "blue" });
|
||||
expect(isDeviceTrusted()).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers trust across a reload, so a trusted device still paints from cache", () => {
|
||||
setDeviceTrusted(true);
|
||||
expect(store.get("ihasmail:deviceTrusted")).toBe("1");
|
||||
setDeviceTrusted(false);
|
||||
expect(store.has("ihasmail:deviceTrusted")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the account's data on sign-out but keeps the deliberate exceptions", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
saveJson("mbx-expanded", { a: true });
|
||||
saveJson(accountKey("acct1", "recent"), [{ email: "[email protected]" }]);
|
||||
store.set("ihasmail:lastUser", "[email protected]");
|
||||
store.set("ihasmail:pushDeviceId", "ihasmail-abc");
|
||||
|
||||
clearSignedInData();
|
||||
|
||||
expect(store.has("ihasmail:settings")).toBe(false);
|
||||
expect(store.has("ihasmail:mbx-expanded")).toBe(false);
|
||||
expect(store.has("ihasmail:acct1:recent")).toBe(false);
|
||||
// Kept on purpose: prefills sign-in, and only a trusted device wrote it.
|
||||
expect(store.get("ihasmail:lastUser")).toBe("[email protected]");
|
||||
expect(store.get("ihasmail:pushDeviceId")).toBe("ihasmail-abc");
|
||||
});
|
||||
|
||||
it("clears everything, lastUser included, for an untrusted sign-in", () => {
|
||||
setDeviceTrusted(true);
|
||||
saveJson("settings", { theme: "dark" });
|
||||
store.set("ihasmail:lastUser", "[email protected]");
|
||||
|
||||
clearAllData();
|
||||
|
||||
expect([...store.keys()]).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves keys belonging to anything else alone", () => {
|
||||
setDeviceTrusted(true);
|
||||
store.set("someone-elses-key", "keep me");
|
||||
clearAllData();
|
||||
expect(store.get("someone-elses-key")).toBe("keep me");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
|
||||
@@ -60,9 +60,14 @@ describe("the default theme", () => {
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
// Reads and writes are gated on device trust now, and the gate defaults to
|
||||
// closed. These tests are about `loadJson`'s merge, so open it and put it
|
||||
// back -- an untrusted device is covered by storage.test.ts instead.
|
||||
setDeviceTrusted(true);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
setDeviceTrusted(false);
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Sign out an untrusted device after a few minutes of inactivity.
|
||||
*
|
||||
* This exists because the alternative does not work. Asking someone to
|
||||
* remember to sign out relies on the person, which is the part you cannot rely
|
||||
* on when the machine is not theirs — and a browser cannot help: custom
|
||||
* `beforeunload` text was removed years ago, and no event fires at all for the
|
||||
* case that actually matters, which is walking away from a signed-in screen.
|
||||
*
|
||||
* A timer needs nobody's cooperation, so that is what this is.
|
||||
*
|
||||
* Trusted devices are left alone entirely: the whole point of saying a machine
|
||||
* is yours is not being signed out of it.
|
||||
*/
|
||||
const IDLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Coarse enough not to fire constantly, broad enough to catch a person reading. */
|
||||
const ACTIVITY = ["mousedown", "keydown", "touchstart", "scroll", "focus"] as const;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let onExpire: (() => void) | null = null;
|
||||
|
||||
function arm(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const fn = onExpire;
|
||||
stopIdleLogout();
|
||||
fn?.();
|
||||
}, IDLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading a long message is not idleness, but it produces no events either.
|
||||
* Visibility is the honest signal available: a hidden tab is one nobody is
|
||||
* looking at, so the clock keeps running; showing it again is activity.
|
||||
*/
|
||||
function onVisibility(): void {
|
||||
if (document.visibilityState === "visible") arm();
|
||||
}
|
||||
|
||||
export function startIdleLogout(expire: () => void): void {
|
||||
stopIdleLogout();
|
||||
onExpire = expire;
|
||||
for (const ev of ACTIVITY) window.addEventListener(ev, arm, { passive: true, capture: true });
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
arm();
|
||||
}
|
||||
|
||||
export function stopIdleLogout(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
onExpire = null;
|
||||
for (const ev of ACTIVITY) window.removeEventListener(ev, arm, { capture: true });
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
/** Exported for tests, which should not wait five real minutes. */
|
||||
export const IDLE_TIMEOUT_MS = IDLE_MS;
|
||||
@@ -1,6 +1,93 @@
|
||||
/**
|
||||
* Local storage, gated on whether this device is trusted.
|
||||
*
|
||||
* Everything here is a *cache* or a screen preference — the real copy lives in
|
||||
* the account's JMAP Files (see `settingsSync`). That makes it safe to write
|
||||
* nothing at all, which is what an untrusted device does: on a shared or public
|
||||
* machine the cost of a stale first frame is nothing beside leaving someone's
|
||||
* address book on it.
|
||||
*
|
||||
* Reads are gated as well as writes. A machine that was trusted once still has
|
||||
* the residue, and honouring it would let a previous session's data surface in
|
||||
* a later untrusted one.
|
||||
*/
|
||||
const PREFIX = "ihasmail:";
|
||||
|
||||
/**
|
||||
* Kept when a session ends. Everything else is cleared, so a key added later
|
||||
* is forgotten by default rather than by nobody having thought about it.
|
||||
*
|
||||
* - `lastUser` is a deliberate convenience: it prefills the sign-in field, and
|
||||
* it is only ever written by a trusted device in the first place.
|
||||
* - `deviceTrusted` is how the next boot knows to read at all.
|
||||
* - `pushDeviceId` is a random id for this browser, so re-subscribing replaces
|
||||
* rather than accumulates. The subscription itself is removed on sign-out.
|
||||
*/
|
||||
const KEEP_ON_SIGN_OUT = ["lastUser", "deviceTrusted", "pushDeviceId"];
|
||||
|
||||
const TRUST_KEY = `${PREFIX}deviceTrusted`;
|
||||
|
||||
/**
|
||||
* Read at module load rather than waiting for the session, so a trusted device
|
||||
* still paints its first frame from cache. An untrusted one has nothing to
|
||||
* read, so there is nothing to wait for.
|
||||
*/
|
||||
let trusted = (() => {
|
||||
try {
|
||||
return localStorage.getItem(TRUST_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
export function isDeviceTrusted(): boolean {
|
||||
return trusted;
|
||||
}
|
||||
|
||||
/** Set from the session's `remember` flag, which is the answer given at sign-in. */
|
||||
export function setDeviceTrusted(value: boolean): void {
|
||||
trusted = value;
|
||||
try {
|
||||
if (value) localStorage.setItem(TRUST_KEY, "1");
|
||||
else localStorage.removeItem(TRUST_KEY);
|
||||
} catch {
|
||||
/* private mode: the in-memory flag still holds for this tab */
|
||||
}
|
||||
}
|
||||
|
||||
/** Every `ihasmail:` key currently present, without the prefix. */
|
||||
function ownKeys(): string[] {
|
||||
const out: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(PREFIX)) out.push(k.slice(PREFIX.length));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop what this browser was holding for a signed-in account. Called on every
|
||||
* sign-out, trusted or not: handing a laptop to someone else is the same
|
||||
* exposure as a public machine, only quieter.
|
||||
*/
|
||||
export function clearSignedInData(): void {
|
||||
for (const key of ownKeys()) {
|
||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
||||
removeKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything, `lastUser` included — for signing in to a device we do not trust. */
|
||||
export function clearAllData(): void {
|
||||
for (const key of ownKeys()) removeKey(key);
|
||||
}
|
||||
|
||||
export function loadJson<T>(key: string, fallback: T): T {
|
||||
if (!trusted) return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
if (raw == null) return fallback;
|
||||
@@ -11,6 +98,7 @@ export function loadJson<T>(key: string, fallback: T): T {
|
||||
}
|
||||
|
||||
export function loadRaw<T>(key: string, fallback: T): T {
|
||||
if (!trusted) return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
if (raw == null) return fallback;
|
||||
@@ -21,6 +109,7 @@ export function loadRaw<T>(key: string, fallback: T): T {
|
||||
}
|
||||
|
||||
export function saveJson(key: string, value: unknown): void {
|
||||
if (!trusted) return;
|
||||
try {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||
} catch {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
||||
@@ -93,6 +94,10 @@ export function encodeKey(buffer: ArrayBuffer | null): string {
|
||||
/** A stable id for this browser, so a re-subscribe replaces rather than piles up. */
|
||||
export function deviceClientId(): string {
|
||||
const KEY = "ihasmail:pushDeviceId";
|
||||
// An untrusted device gets a per-session id instead of a stored one. It is
|
||||
// the same trade private mode already makes below: re-subscribing will not
|
||||
// reuse it, which costs nothing when push is refused there anyway.
|
||||
if (!isDeviceTrusted()) return `ihasmail-${crypto.randomUUID()}`;
|
||||
try {
|
||||
const existing = localStorage.getItem(KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
import {
|
||||
@@ -67,6 +68,12 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
if (Notification.permission === "denied") {
|
||||
return { ok: false, reason: "Notifications are blocked for this site in your browser's settings." };
|
||||
}
|
||||
// A subscription outlives the tab and belongs to the account, not the
|
||||
// session -- so on a machine the user has told us is not theirs, it would go
|
||||
// on delivering their mail to it long after they had gone.
|
||||
if (!isDeviceTrusted()) {
|
||||
return { ok: false, reason: "Background notifications need a device you have marked as your own. Sign in again with \u201CThis is my own device\u201D ticked." };
|
||||
}
|
||||
const key = applicationServerKey();
|
||||
if (!key) return { ok: false, reason: "This mail server does not publish a push key." };
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
@@ -441,7 +442,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
|
||||
set({ recent: next });
|
||||
try {
|
||||
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next));
|
||||
saveJson(accountKey(get().accountId, "recent"), next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -466,7 +467,7 @@ useSession.subscribe((s) => {
|
||||
const accountId = s.accountFor(CAP.contacts);
|
||||
let recent: EmailAddress[] = [];
|
||||
try {
|
||||
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[];
|
||||
recent = loadRaw<EmailAddress[]>(accountKey(accountId, "recent"), []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { setServerLocale } from "@/lib/datetime";
|
||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/webpush";
|
||||
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
|
||||
@@ -81,6 +83,11 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stopIdleLogout();
|
||||
// Unconditional. The push subscription above is removed for exactly this
|
||||
// reason -- that a browser left holding someone's mail is somebody else's
|
||||
// problem next -- and the address book cached here is the same argument.
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
set({ status: "anonymous", session: null, accountId: null });
|
||||
},
|
||||
@@ -112,6 +119,19 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
client.session = s;
|
||||
setServerLocale(s.ihasmail?.userLocale);
|
||||
// `remember` is the answer to "is this device yours", given at sign-in and
|
||||
// carried on the session -- so a reload arrives at the same answer without
|
||||
// the client storing it, which on an untrusted device it could not do anyway.
|
||||
const trusted = Boolean(s.ihasmail?.remember);
|
||||
setDeviceTrusted(trusted);
|
||||
if (trusted) {
|
||||
stopIdleLogout();
|
||||
} else {
|
||||
// Residue from an earlier trusted session on this machine is exactly what
|
||||
// an untrusted sign-in is asking us not to keep.
|
||||
clearAllData();
|
||||
startIdleLogout(() => void useSession.getState().logout());
|
||||
}
|
||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||
set({ status: "authenticated", session: s, accountId, error: null });
|
||||
}
|
||||
@@ -119,6 +139,8 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
client.onUnauthenticated(() => {
|
||||
push.stop();
|
||||
stopSettingsSync();
|
||||
stopIdleLogout();
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
// Ask before showing the sign-in form rather than after. A deploy is the
|
||||
// usual reason to be signed out here, and reloading a form someone has
|
||||
|
||||
+11
-6
@@ -22,7 +22,7 @@ export function LoginPage() {
|
||||
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -34,8 +34,8 @@ export function LoginPage() {
|
||||
try {
|
||||
// No two-factor code: the field is not on this form until the flow works
|
||||
// end to end, and the server treats an absent code as none given.
|
||||
await login(username.trim(), password, "", remember);
|
||||
localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
await login(username.trim(), password, "", trustDevice);
|
||||
if (trustDevice) localStorage.setItem("ihasmail:lastUser", username.trim());
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === "invalid_credentials") {
|
||||
@@ -74,10 +74,15 @@ export function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="check" style={{ marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
||||
<span>Keep me signed in on this device</span>
|
||||
<label className="check" style={{ marginBottom: 4 }}>
|
||||
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
|
||||
<span>This is my own device</span>
|
||||
</label>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
{trustDevice
|
||||
? "Stay signed in, and keep settings and recent addresses on this computer."
|
||||
: "Signed out after 5 minutes of inactivity, and nothing is kept on this computer. Leave this unticked on a shared or public one."}
|
||||
</p>
|
||||
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
|
||||
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
|
||||
Reference in New Issue
Block a user