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
56 changes: 56 additions & 0 deletions portal/app/TwoFactorField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import type { TwoFactorProof } from "../lib/api";

export interface TwoFactorEntry {
recovery: boolean;
value: string;
}

export const EMPTY_TWO_FACTOR_ENTRY: TwoFactorEntry = { recovery: false, value: "" };

export function entryProof(entry: TwoFactorEntry): TwoFactorProof | null {
const value = entry.value.trim();
if (!value) return null;
return entry.recovery ? { recoveryCode: value } : { code: value };
}

const ALL_METHODS = ["totp", "recovery"];

export function TwoFactorField({ entry, onChange, methods = ALL_METHODS, autoFocus }: {
entry: TwoFactorEntry;
onChange: (entry: TwoFactorEntry) => void;
/** What the server will accept; one that lost its seed key offers recovery codes only. */
methods?: string[];
autoFocus?: boolean;
}) {
const recovery = entry.recovery || !methods.includes("totp");
const canSwitch = methods.includes("totp") && methods.includes("recovery");
return (
<div className="flex flex-col gap-1.5">
<input
key={recovery ? "recovery" : "totp"}
type="text"
value={entry.value}
onChange={(e) => onChange({ recovery, value: e.target.value })}
autoFocus={autoFocus}
autoComplete={recovery ? "off" : "one-time-code"}
inputMode={recovery ? "text" : "numeric"}
autoCapitalize="none"
spellCheck={false}
maxLength={recovery ? 32 : 8}
placeholder={recovery ? "Recovery code" : "6-digit code"}
className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm font-mono tracking-wider text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
{canSwitch && (
<button
type="button"
onClick={() => onChange({ recovery: !recovery, value: "" })}
className="self-start text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
{recovery ? "Use an authenticator code" : "Use a recovery code"}
</button>
)}
</div>
);
}
22 changes: 19 additions & 3 deletions portal/app/account/ChangePasswordModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useState, type FormEvent } from "react";
import { deriveAuthKey, deriveKek, wrapUserSecrets, base64ToBytes, bytesToBase64, unwrapUserSecretsDek, unwrapUserSecretsX25519 } from "../../lib/crypto";
import { getMe, updatePassword, ApiError } from "../../lib/api";
import { getMe, updatePassword, twoFactorMessage, ApiError } from "../../lib/api";
import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry } from "../TwoFactorField";

interface Props {
accountId: string;
Expand All @@ -18,6 +19,8 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
const [needsCode, setNeedsCode] = useState(false);
const [twoFactor, setTwoFactor] = useState<TwoFactorEntry>(EMPTY_TWO_FACTOR_ENTRY);

async function handleSubmit(e: FormEvent) {
e.preventDefault();
Expand All @@ -35,6 +38,13 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce
setLoading(false);
return;
}
const proof = entryProof(twoFactor);
if (me.two_factor_enabled && !proof) {
setNeedsCode(true);
setError("Enter a code from your authenticator app to confirm the change.");
setLoading(false);
return;
}

const oldKek = await deriveKek(currentPassword, accountId);
const wrappedBytes = base64ToBytes(me.wrapped_user_secrets);
Expand All @@ -52,11 +62,16 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce
const newWrappedBytes = await wrapUserSecrets(newKek, dek, x25519Private);
const newWrappedUserSecrets = bytesToBase64(newWrappedBytes);

const auth = await updatePassword(oldAuthKey, newAuthKey, newWrappedUserSecrets, token);
const auth = await updatePassword(oldAuthKey, newAuthKey, newWrappedUserSecrets, token, proof);
setDone(true);
onSuccess(auth.jwt_token, auth.refresh_token);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
const refusal = twoFactorMessage(err);
if (refusal) {
setNeedsCode(true);
setError(refusal);
setTwoFactor((entry) => ({ ...entry, value: "" }));
} else if (err instanceof ApiError && err.status === 401) {
setError("Current password is incorrect.");
} else {
setError(err instanceof Error ? err.message : "Password change failed.");
Expand Down Expand Up @@ -84,6 +99,7 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce
className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" />
<input type="password" placeholder="Confirm new password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" />
{needsCode && <TwoFactorField entry={twoFactor} onChange={setTwoFactor} autoFocus />}
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2 mt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-[#1e1e2e] hover:border-zinc-600 text-zinc-400 hover:text-white text-sm transition-colors">Cancel</button>
Expand Down
95 changes: 79 additions & 16 deletions portal/app/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,76 @@ import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { deriveAuthKey } from "../../lib/crypto";
import { getChallenge, login } from "../../lib/api";
import {
ApiError, getChallenge, login, loginSecondFactor, twoFactorMessage,
type AuthResponse, type TwoFactorChallenge,
} from "../../lib/api";
import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry } from "../TwoFactorField";

export default function SigninPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null);
const [twoFactor, setTwoFactor] = useState<TwoFactorEntry>(EMPTY_TWO_FACTOR_ENTRY);
const emailRef = useRef<HTMLInputElement>(null);

useEffect(() => { emailRef.current?.focus(); }, []);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
function enter(auth: AuthResponse) {
sessionStorage.setItem("access_token", auth.jwt_token);
sessionStorage.setItem("refresh_token", auth.refresh_token);
sessionStorage.setItem("tier", auth.tier);
if (auth.trial_ends_at) sessionStorage.setItem("trial_ends_at", String(auth.trial_ends_at));

router.push("/account");
}

function backToPassword(message = "") {
setChallenge(null);
setTwoFactor(EMPTY_TWO_FACTOR_ENTRY);
setError(message);
}

async function attempt(work: () => Promise<void>) {
setError("");
setLoading(true);
try {
const { account_id } = await getChallenge(email);
const authKey = await deriveAuthKey(password, account_id);
const auth = await login(authKey, account_id);

sessionStorage.setItem("access_token", auth.jwt_token);
sessionStorage.setItem("refresh_token", auth.refresh_token);
sessionStorage.setItem("tier", auth.tier);
if (auth.trial_ends_at) sessionStorage.setItem("trial_ends_at", String(auth.trial_ends_at));

router.push("/account");
await work();
} catch (err) {
setError(err instanceof Error ? err.message : "Sign in failed.");
const refusal = twoFactorMessage(err);
// A bare 401 at the code step is the five-minute pending sign-in running out.
if (!refusal && challenge && err instanceof ApiError && err.status === 401) {
backToPassword("This sign-in took too long. Start again.");
} else {
setError(refusal ?? (err instanceof Error ? err.message : "Sign in failed."));
setTwoFactor((entry) => ({ ...entry, value: "" }));
}
} finally {
setLoading(false);
}
}

function handleSubmit(e: React.FormEvent) {
e.preventDefault();
return attempt(async () => {
const { account_id } = await getChallenge(email);
const authKey = await deriveAuthKey(password, account_id);
const result = await login(authKey, account_id);
if ("challenge" in result) setChallenge(result.challenge);
else enter(result.session);
});
}

function handleCode(e: React.FormEvent) {
e.preventDefault();
const proof = entryProof(twoFactor);
if (!challenge || !proof) return;
return attempt(async () => enter(await loginSecondFactor(challenge.pending_token, proof)));
}

return (
<main className="relative min-h-screen flex items-center justify-center px-4 overflow-hidden">
{/* Ambient glow */}
Expand All @@ -56,11 +92,37 @@ export default function SigninPage() {

{/* Card */}
<div className="rounded-2xl border border-[#1e1e2e] bg-[#111118] p-8 shadow-[0_0_60px_rgba(6,182,212,0.04)]">
<h1 className="text-xl font-bold text-white mb-1">Welcome back</h1>
<h1 className="text-xl font-bold text-white mb-1">{challenge ? "Two-factor authentication" : "Welcome back"}</h1>
<p className="text-sm text-zinc-500 mb-6">
Your password never leaves your device.
{challenge
? "Enter the code from your authenticator app, or one of your recovery codes."
: "Your password never leaves your device."}
</p>

{challenge ? (
<form onSubmit={handleCode} className="flex flex-col gap-4">
<TwoFactorField entry={twoFactor} onChange={setTwoFactor} methods={challenge.methods} autoFocus />

{error && (
<p className="text-sm text-red-400 -mt-1">{error}</p>
)}

<button
type="submit"
disabled={loading || !twoFactor.value.trim()}
className="mt-1 w-full py-3 rounded-xl bg-cyan-500 hover:bg-cyan-400 disabled:bg-[#1e1e2e] disabled:text-zinc-500 disabled:cursor-default text-black font-semibold text-sm transition-all duration-200 hover:shadow-[0_0_24px_rgba(6,182,212,0.4)]"
>
{loading ? "Verifying…" : "Verify"}
</button>
<button
type="button"
onClick={() => backToPassword()}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Back
</button>
</form>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Field label="Email" type="email" value={email} onChange={setEmail} inputRef={emailRef} required />
<Field label="Master password" type="password" value={password} onChange={setPassword} required />
Expand All @@ -77,6 +139,7 @@ export default function SigninPage() {
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
)}
</div>

<p className="mt-5 text-center text-sm text-zinc-500">
Expand Down
67 changes: 64 additions & 3 deletions portal/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ export class ApiError extends Error {
super(message);
this.name = "ApiError";
}

/** The `{"error": CODE, ...}` body the server sends with a refusal it wants told apart. */
get body(): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(this.message);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
}

async function request<T>(
Expand Down Expand Up @@ -55,8 +65,37 @@ export interface MeResponse {
handle: string;
handle_is_custom: boolean;
handle_managed?: boolean;
two_factor_enabled?: boolean;
}

export type TwoFactorProof = { code: string } | { recoveryCode: string };

function proofBody(proof?: TwoFactorProof | null): { code?: string; recovery_code?: string } {
if (!proof) return {};
return "code" in proof ? { code: proof.code.trim() } : { recovery_code: proof.recoveryCode.trim() };
}

const TWO_FACTOR_MESSAGES: Record<string, string> = {
TWO_FACTOR_REQUIRED: "Enter the code from your authenticator app.",
TWO_FACTOR_INVALID: "Wrong code. Try the next one your app shows.",
TWO_FACTOR_LOCKED: "Too many wrong codes. Try again in 15 minutes.",
TWO_FACTOR_UNAVAILABLE: "This server cannot check authenticator codes right now. Use a recovery code.",
};

/** What to tell the user when a refusal is about the second factor, else null. */
export function twoFactorMessage(err: unknown): string | null {
const code = err instanceof ApiError ? err.body?.error : null;
return typeof code === "string" ? (TWO_FACTOR_MESSAGES[code] ?? null) : null;
}

export interface TwoFactorChallenge {
pending_token: string;
/** "totp" and/or "recovery". */
methods: string[];
}

export type LoginResult = { session: AuthResponse } | { challenge: TwoFactorChallenge };

export interface CheckoutResponse {
checkout_url: string;
}
Expand Down Expand Up @@ -115,6 +154,7 @@ export function updatePassword(
newAuthKey: string,
newWrappedUserSecrets: string,
token: string,
proof?: TwoFactorProof | null,
): Promise<AuthResponse> {
return request<AuthResponse>(
"/v1/auth/password",
Expand All @@ -124,16 +164,37 @@ export function updatePassword(
old_auth_key: oldAuthKey,
new_auth_key: newAuthKey,
new_wrapped_user_secrets: newWrappedUserSecrets,
...proofBody(proof),
}),
},
token,
);
}

export function login(authKey: string, accountId: string): Promise<AuthResponse> {
return request<AuthResponse>("/v1/auth/login", {
/** A session, or the challenge an account with two-factor sign-in answers the password with. */
export async function login(authKey: string, accountId: string): Promise<LoginResult> {
try {
const session = await request<AuthResponse>("/v1/auth/login", {
method: "POST",
body: JSON.stringify({ auth_key: authKey, account_id: accountId }),
});
return { session };
} catch (err) {
const body = err instanceof ApiError && err.status === 428 ? err.body : null;
if (body?.error !== "TWO_FACTOR_REQUIRED" || typeof body.pending_token !== "string") throw err;
return {
challenge: {
pending_token: body.pending_token,
methods: Array.isArray(body.methods) ? body.methods.filter((m): m is string => typeof m === "string") : ["totp"],
},
};
}
}

export function loginSecondFactor(pendingToken: string, proof: TwoFactorProof): Promise<AuthResponse> {
return request<AuthResponse>("/v1/auth/login/2fa", {
method: "POST",
body: JSON.stringify({ auth_key: authKey, account_id: accountId }),
body: JSON.stringify({ pending_token: pendingToken, ...proofBody(proof) }),
});
}

Expand Down
Loading