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

import { useEffect, useRef, useState } from "react";
import AuthShell, { PRIMARY_BUTTON } from "../../AuthShell";
import { beginPasskey, type PasskeyCeremony, type PasskeyPurpose } from "../../../lib/api";
import { askPasskey, passkeyMessage, passkeysSupported } from "../../../lib/passkey";

const COPY: Record<PasskeyPurpose, { title: string; body: (email: string) => string; action: string }> = {
sign_in: {
title: "Finish signing in",
body: (email) => `Use your passkey to finish signing in to Voltius as ${email}.`,
action: "Use passkey",
},
step_up: {
title: "Confirm it's you",
body: (email) => `Use your passkey to confirm a change to the Voltius account ${email}.`,
action: "Use passkey",
},
register: {
title: "Add a passkey",
body: (email) => `Create a passkey for the Voltius account ${email}.`,
action: "Create passkey",
},
};

type Phase =
| { kind: "loading" }
| { kind: "ready"; ceremony: PasskeyCeremony; error: string; working: boolean }
| { kind: "done"; openApp?: string }
| { kind: "stopped"; message: string };

export default function PasskeyPage() {
const fragment = useRef<string | null>(null);
const handle = useRef("");
const [phase, setPhase] = useState<Phase>({ kind: "loading" });

useEffect(() => {
// The handle is a one-time secret: read it once, then keep it out of the address bar and history.
if (fragment.current === null) {
fragment.current = window.location.hash.slice(1);
window.history.replaceState(null, "", window.location.pathname);
}
if (fragment.current === "done") {
setPhase({ kind: "done" });
return;
}
handle.current = new URLSearchParams(fragment.current).get("h") ?? "";
if (!handle.current) {
setPhase({ kind: "stopped", message: "This page only works when Voltius opens it. Go back to the app and try again." });
return;
}
if (!passkeysSupported()) {
setPhase({ kind: "stopped", message: "This browser cannot use passkeys. Go back to Voltius and use a code instead." });
return;
}
let current = true;
beginPasskey(handle.current)
.then((ceremony) => current && setPhase({ kind: "ready", ceremony, error: "", working: false }))
.catch((err) => current && setPhase({ kind: "stopped", message: `${passkeyMessage(err)} Go back to Voltius and try again.` }));
return () => {
current = false;
};
}, []);

async function run(ceremony: PasskeyCeremony) {
setPhase({ kind: "ready", ceremony, error: "", working: true });
try {
const { redirect } = await askPasskey(handle.current, ceremony);
if (!redirect) throw new Error("Nothing asked for this passkey.");
const toApp = redirect.startsWith("voltius:");
setPhase({ kind: "done", openApp: toApp ? redirect : undefined });
window.location.href = redirect;
} catch (err) {
setPhase({ kind: "ready", ceremony, error: passkeyMessage(err), working: false });
}
}

if (phase.kind === "loading") {
return (
<AuthShell>
<p className="text-sm text-zinc-500">Loading…</p>
</AuthShell>
);
}
if (phase.kind === "stopped") {
return (
<AuthShell>
<h1 className="text-xl font-bold text-white mb-1">Passkey</h1>
<p className="text-sm text-zinc-400">{phase.message}</p>
</AuthShell>
);
}
if (phase.kind === "done") {
return (
<AuthShell>
<h1 className="text-xl font-bold text-white mb-1">Done</h1>
<p className="text-sm text-zinc-400">Go back to Voltius. You can close this tab.</p>
{phase.openApp && (
<a href={phase.openApp} className={`mt-6 block text-center ${PRIMARY_BUTTON}`}>
Open Voltius
</a>
)}
</AuthShell>
);
}

const { ceremony, error, working } = phase;
const copy = COPY[ceremony.purpose];
return (
<AuthShell>
<h1 className="text-xl font-bold text-white mb-1">{copy.title}</h1>
<p className="text-sm text-zinc-400 mb-2 break-words">{copy.body(ceremony.email)}</p>
<p className="text-sm text-zinc-500 mb-6">Only continue if you just asked for this in the Voltius app on this device.</p>
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
<button type="button" onClick={() => run(ceremony)} disabled={working} className={PRIMARY_BUTTON}>
{working ? "Waiting for your passkey…" : copy.action}
</button>
</AuthShell>
);
}
33 changes: 33 additions & 0 deletions portal/app/AuthShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Image from "next/image";
import type { ReactNode } from "react";

/** The centred card every signed-out page sits in. */
export default function AuthShell({ children, above, below }: {
children: ReactNode;
above?: ReactNode;
below?: ReactNode;
}) {
return (
<main className="relative min-h-screen flex items-center justify-center px-4 overflow-hidden">
<div className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-cyan-500/8 rounded-full blur-[120px]" />
</div>
<div className="pointer-events-none absolute inset-0 -z-10 [background-image:repeating-linear-gradient(to_right,rgb(9,38,44)_0_1px,transparent_1px_72px),repeating-linear-gradient(to_bottom,rgb(9,38,44)_0_1px,transparent_1px_72px)] [mask-image:radial-gradient(ellipse_at_center,black_20%,transparent_70%)]" />

<div className="w-full max-w-md">
<a href="https://voltius.app" className="flex items-center justify-center gap-2.5 mb-8">
<Image src="/logo.png" alt="Voltius" width={32} height={32} />
<span className="text-white font-semibold text-lg">Voltius</span>
</a>
{above}
<div className="rounded-2xl border border-[#1e1e2e] bg-[#111118] p-8 shadow-[0_0_60px_rgba(6,182,212,0.04)]">
{children}
</div>
{below}
</div>
</main>
);
}

export const PRIMARY_BUTTON =
"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)]";
27 changes: 27 additions & 0 deletions portal/app/TwoFactorField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

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

export interface TwoFactorEntry {
recovery: boolean;
Expand All @@ -17,6 +18,32 @@ export function entryProof(entry: TwoFactorEntry): TwoFactorProof | null {

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

export function twoFactorPrompt(methods: string[]): string {
const typed = methods.includes("totp")
? "a code from your authenticator app or a recovery code"
: "one of your recovery codes";
return methods.includes("passkey") && passkeysSupported() ? `Use your passkey, or enter ${typed}.` : `Enter ${typed}.`;
}

/** The alternative to typing a code, for an account with a passkey in a browser that can use one. */
export function PasskeyButton({ methods, onClick, disabled }: {
methods: string[];
onClick: () => void;
disabled?: boolean;
}) {
if (!methods.includes("passkey") || !passkeysSupported()) return null;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-full py-2.5 rounded-xl border border-[#1e1e2e] hover:border-cyan-500/50 disabled:opacity-50 text-sm font-medium text-white transition-colors"
>
Use a passkey
</button>
);
}

export function TwoFactorField({ entry, onChange, methods = ALL_METHODS, autoFocus }: {
entry: TwoFactorEntry;
onChange: (entry: TwoFactorEntry) => void;
Expand Down
37 changes: 28 additions & 9 deletions portal/app/account/ChangePasswordModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

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

interface Props {
accountId: string;
Expand All @@ -19,11 +24,15 @@ 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 [methods, setMethods] = useState<string[] | null>(null);
const [twoFactor, setTwoFactor] = useState<TwoFactorEntry>(EMPTY_TWO_FACTOR_ENTRY);

async function handleSubmit(e: FormEvent) {
function handleSubmit(e: FormEvent) {
e.preventDefault();
return change(async () => entryProof(twoFactor));
}

async function change(prove: () => Promise<TwoFactorProof | null>) {
if (!currentPassword) { setError("Current password required"); return; }
if (newPassword.length < 8) { setError("New password must be at least 8 characters"); return; }
if (newPassword === currentPassword) { setError("New password must differ from current"); return; }
Expand All @@ -38,10 +47,11 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce
setLoading(false);
return;
}
const proof = entryProof(twoFactor);
const proof = await prove();
if (me.two_factor_enabled && !proof) {
setNeedsCode(true);
setError("Enter a code from your authenticator app to confirm the change.");
const offered = await getFactorMethods(token);
setMethods(offered);
setError(twoFactorPrompt(offered));
setLoading(false);
return;
}
Expand All @@ -68,7 +78,7 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce
} catch (err) {
const refusal = twoFactorMessage(err);
if (refusal) {
setNeedsCode(true);
setMethods((offered) => offered ?? ["totp", "recovery"]);
setError(refusal);
setTwoFactor((entry) => ({ ...entry, value: "" }));
} else if (err instanceof ApiError && err.status === 401) {
Expand Down Expand Up @@ -99,7 +109,16 @@ 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 />}
{methods && (
<>
<PasskeyButton
methods={methods}
disabled={loading}
onClick={() => change(async () => ({ passkeyCode: await inlinePasskeyCode(() => passkeyStepUpHandle(token)) }))}
/>
<TwoFactorField entry={twoFactor} onChange={setTwoFactor} methods={methods} 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
Loading
Loading