Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"country-flag-emoji-polyfill": "^0.1.8",
"es-module-lexer": "^2.3.1",
"i18next": "^26.3.3",
"qrcode-generator": "2.0.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 36 additions & 8 deletions src/components/layout/AuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useNotificationStore } from "@/stores/notificationStore";
import { VaultUnreadableError } from "@/services/vaultErrors";
import { VaultBackups } from "@/components/shared/VaultBackups";
import { ServerUrlField } from "@/components/shared/ServerUrlField";
import { TwoFactorStep, useTwoFactorChallenge } from "@/components/shared/TwoFactorStep";
import { lastServerUrl } from "@/utils/serverInstance";
import { useSystemAuthPrompt } from "@/hooks/useSystemAuthPrompt";
import { rebindAfterPassword, unlockWithSystemAuth } from "@/services/vaultBinding";
Expand Down Expand Up @@ -56,16 +57,29 @@ export default function AuthPage({ isLocked, vaultUnreadable, systemAuth, onRead
setConfirm("");
};

const wrap = async (fn: () => Promise<void>) => {
const twoFactor = useTwoFactorChallenge();

// Not a bad password, so not the password form.
const unlessUnreadable = async (work: () => Promise<void>) => {
try {
await work();
} catch (e) {
if (!(e instanceof VaultUnreadableError)) throw e;
twoFactor.cancel();
setUnreadable("wrong-key");
}
};

const wrap = async (fn: () => Promise<void>, after?: () => Promise<void>) => {
setLoading(true);
setError("");
try {
await fn();
onReady();
await unlessUnreadable(() => twoFactor.run(fn, async () => {
await after?.();
onReady();
}));
} catch (e) {
// Not a bad password, so not the password form.
if (e instanceof VaultUnreadableError) setUnreadable("wrong-key");
else setError(e instanceof Error ? e.message : String(e));
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -130,13 +144,27 @@ export default function AuthPage({ isLocked, vaultUnreadable, systemAuth, onRead
);
}

// ── Second factor (the server accepted the password) ─────────────────────

if (twoFactor.challenge) {
const { methods, resume } = twoFactor.challenge;
return (
<Layout>
<TwoFactorStep
challenge={{ methods, resume: (proof) => unlessUnreadable(() => resume(proof)) }}
onCancel={twoFactor.cancel}
inputClassName={INPUT_CLASS}
/>
</Layout>
);
}

// ── Locked (vault exists, need password) ─────────────────────────────────

if (isLocked) {
const submit = async (e: React.FormEvent) => {
e.preventDefault();
await wrap(async () => {
await login(password);
await wrap(() => login(password), async () => {
if (bindingLost) await rebindAfterPassword(password, t("layout.appLock.sealReason"));
});
};
Expand Down
75 changes: 75 additions & 0 deletions src/components/layout/AuthPage.twoFactor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

const h = vi.hoisted(() => ({ login: vi.fn() }));

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } }));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
vi.mock("./LogoBadge", () => ({ default: () => null }));
vi.mock("@/services/account", () => ({
createLocalAccountNoPassword: vi.fn(),
createServerAccount: vi.fn(),
login: h.login,
}));
vi.mock("@/stores/notificationStore", () => ({
useNotificationStore: (sel: (s: unknown) => unknown) => sel({ addToast: vi.fn() }),
}));
vi.mock("@/components/shared/VaultBackups", () => ({
VaultBackups: () => <div data-testid="vault-backups" />,
}));

import AuthPage from "./AuthPage";
import { TwoFactorRequiredError, type TwoFactorProof } from "@/services/twoFactorProof";
import { VaultUnreadableError } from "@/services/vaultErrors";

beforeEach(() => {
cleanup();
vi.clearAllMocks();
});

const submit = () => userEvent.click(document.querySelector<HTMLButtonElement>('button[type="submit"]')!);

async function reachCodeStep(complete: (proof: TwoFactorProof) => Promise<unknown>, onReady = vi.fn()) {
h.login.mockRejectedValue(new TwoFactorRequiredError(["totp", "recovery"], complete));
render(<AuthPage isLocked onReady={onReady} />);
fireEvent.change(screen.getByPlaceholderText("layout.auth.masterPasswordPlaceholder"), { target: { value: "pw" } });
await submit();
const code = await screen.findByPlaceholderText("layout.twoFactor.codePlaceholder");
expect(onReady).not.toHaveBeenCalled();
return { code, onReady };
}

test("unlocking an account that needs a code opens the app only once the code is accepted", async () => {
const complete = vi.fn(async (_proof: TwoFactorProof) => {});
const { code, onReady } = await reachCodeStep(complete);

fireEvent.change(code, { target: { value: "123456" } });
await submit();

await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1));
expect(complete).toHaveBeenCalledWith({ code: "123456" });
});

test("cancelling the code step returns to the password form", async () => {
await reachCodeStep(async () => {});
await userEvent.click(screen.getByRole("button", { name: "common.action.cancel" }));
expect(screen.getByPlaceholderText("layout.auth.masterPasswordPlaceholder")).toBeTruthy();
});

test("an unreadable vault found after the code lands on recovery, not on a code error", async () => {
const { code, onReady } = await reachCodeStep(async () => {
throw new VaultUnreadableError();
});

fireEvent.change(code, { target: { value: "123456" } });
await submit();

expect(await screen.findByText("layout.auth.vaultUnreadableTitle")).toBeTruthy();
expect(onReady).not.toHaveBeenCalled();
});
98 changes: 66 additions & 32 deletions src/components/layout/CloudAuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { Icon } from "@iconify/react";
import { Modal, ModalCard } from "@/components/shared/Modal";
import { useUIStore, type CloudAuthMode } from "@/stores/uiStore";
import { useSubscriptionStore } from "@/stores/subscriptionStore";
import { authenticateServerAccount, getAccountMode, linkToCloud, setMasterPassword, signInToCloud } from "@/services/account";
import {
authenticateServerAccount, getAccountMode, getCurrentUserEmail, linkToCloud, login, setMasterPassword, signInToCloud,
} from "@/services/account";
import { addAccount } from "@/services/savedAccounts";
import { startRealtimeSync, syncOnLogin, syncOnLoginReplace } from "@/services/sync";
import { startRealtimeSync, syncNow, syncOnLogin, syncOnLoginReplace } from "@/services/sync";
import { ServerUrlField } from "@/components/shared/ServerUrlField";
import { TwoFactorStep, useTwoFactorChallenge } from "@/components/shared/TwoFactorStep";
import { lastServerUrl } from "@/utils/serverInstance";

const AUTH_INPUT_CLASS =
Expand All @@ -16,8 +19,11 @@ const AUTH_INPUT_CLASS =
export default function CloudAuthModal() {
const { t } = useTranslation();
const open = useUIStore((s) => s.cloudAuthOpen);
const mode = useUIStore((s) => s.cloudAuthMode);
const isAdd = useUIStore((s) => s.cloudAuthPurpose === "add");
const purpose = useUIStore((s) => s.cloudAuthPurpose);
const isAdd = purpose === "add";
// The session this device held was ended by the server; the account itself is unchanged.
const isReauth = purpose === "reauth";
const mode = useUIStore((s) => (s.cloudAuthPurpose === "reauth" ? "signin" : s.cloudAuthMode));
const setMode = useUIStore((s) => s.setCloudAuthMode);
const onClose = useUIStore((s) => s.closeCloudAuth);
const reloadSubscription = useSubscriptionStore((s) => s.load);
Expand All @@ -29,12 +35,15 @@ export default function CloudAuthModal() {
const [serverUrl, setServerUrl] = useState(lastServerUrl);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const twoFactor = useTwoFactorChallenge();

useEffect(() => {
if (!open) return;
getAccountMode().then(setAccountMode).catch(() => setAccountMode(null));
if (isReauth) getCurrentUserEmail().then((current) => setEmail(current ?? "")).catch(() => {});
setError("");
}, [open]);
twoFactor.cancel();
}, [open, isReauth]);

if (!open) return null;

Expand Down Expand Up @@ -64,30 +73,37 @@ export default function CloudAuthModal() {
if (password !== confirm) { setError(t("layout.auth.errorPasswordMismatch")); return; }
}

const connected = async (firstSync: () => Promise<void>) => {
firstSync().catch(() => {});
startRealtimeSync();
await reloadSubscription().catch(() => {});
onClose();
setEmail("");
setPassword("");
setConfirm("");
};

setLoading(true);
setError("");
try {
if (isAdd) {
if (isReauth) {
await twoFactor.run(() => login(password), () => connected(syncNow));
} else if (isAdd) {
// Nothing of the current session is touched until the server accepts these.
const session = await authenticateServerAccount(isRegister ? "register" : "signin", email, password, normalizedUrl);
await addAccount(session);
onClose();
return;
}
if (isRegister) {
await twoFactor.run(
() => authenticateServerAccount(isRegister ? "register" : "signin", email, password, normalizedUrl),
async (session) => {
await addAccount(session);
onClose();
},
);
} else if (isRegister) {
if (isLocalNoPassword) await setMasterPassword(password);
await linkToCloud(email, normalizedUrl);
syncOnLogin().catch(() => {});
await connected(syncOnLogin);
} else {
await signInToCloud(email, password, normalizedUrl);
syncOnLoginReplace().catch(() => {});
await twoFactor.run(() => signInToCloud(email, password, normalizedUrl), () => connected(syncOnLoginReplace));
}
startRealtimeSync();
await reloadSubscription().catch(() => {});
onClose();
setEmail("");
setPassword("");
setConfirm("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
Expand All @@ -107,19 +123,31 @@ export default function CloudAuthModal() {
</div>
<div>
<p className="text-base font-semibold text-(--t-text-primary) mb-1">
{isAdd
? t("layout.cloudAuthModal.addTitle")
: isRegister ? t("layout.cloudAuthModal.registerTitle") : t("layout.cloudAuthModal.signinTitle")}
{isReauth
? t("layout.cloudAuthModal.reauthTitle")
: isAdd
? t("layout.cloudAuthModal.addTitle")
: isRegister ? t("layout.cloudAuthModal.registerTitle") : t("layout.cloudAuthModal.signinTitle")}
</p>
<p className="text-sm text-(--t-text-muted) leading-relaxed">
{isAdd
? t("layout.cloudAuthModal.addDesc")
: isRegister ? t("layout.cloudAuthModal.registerDesc") : t("layout.cloudAuthModal.signinDesc")}
{isReauth
? t("layout.cloudAuthModal.reauthDesc")
: isAdd
? t("layout.cloudAuthModal.addDesc")
: isRegister ? t("layout.cloudAuthModal.registerDesc") : t("layout.cloudAuthModal.signinDesc")}
</p>
</div>
</div>

<div className="flex rounded-lg overflow-hidden border border-(--t-border)">
{twoFactor.challenge ? (
<TwoFactorStep
challenge={twoFactor.challenge}
onCancel={twoFactor.cancel}
inputClassName={AUTH_INPUT_CLASS}
buttonClassName="py-2.5"
/>
) : (<>
{!isReauth && <div className="flex rounded-lg overflow-hidden border border-(--t-border)">
{(["signin", "register"] as CloudAuthMode[]).map((next) => {
const active = mode === next;
return (
Expand All @@ -134,12 +162,14 @@ export default function CloudAuthModal() {
</button>
);
})}
</div>
</div>}

<form onSubmit={submit} className="space-y-2">
<AuthInput type="email" placeholder={t("layout.auth.emailPlaceholder")} value={email} onChange={setEmail} autoFocus />
<AuthInput type="email" placeholder={t("layout.auth.emailPlaceholder")} value={email} onChange={setEmail}
autoFocus={!isReauth} disabled={isReauth} />
{!isRegister && (
<AuthInput type="password" placeholder={t("layout.auth.masterPasswordPlaceholder")} value={password} onChange={setPassword} />
<AuthInput type="password" placeholder={t("layout.auth.masterPasswordPlaceholder")} value={password} onChange={setPassword}
autoFocus={isReauth} />
)}
{needsNewPassword && (
<>
Expand All @@ -148,7 +178,7 @@ export default function CloudAuthModal() {
</>
)}

<ServerUrlField value={serverUrl} onChange={setServerUrl} inputClassName={AUTH_INPUT_CLASS} />
{!isReauth && <ServerUrlField value={serverUrl} onChange={setServerUrl} inputClassName={AUTH_INPUT_CLASS} />}

{error && <p className="text-xs px-1 text-(--t-status-error)">{error}</p>}

Expand All @@ -167,6 +197,7 @@ export default function CloudAuthModal() {
{t("common.action.cancel")}
</button>
</form>
</>)}
</ModalCard>
</Modal>
);
Expand All @@ -178,19 +209,22 @@ function AuthInput({
value,
onChange,
autoFocus,
disabled,
}: {
type: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
autoFocus?: boolean;
disabled?: boolean;
}) {
return (
<input
type={type}
placeholder={placeholder}
value={value}
autoFocus={autoFocus}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={AUTH_INPUT_CLASS}
/>
Expand Down
Loading
Loading