Kez/kez-chat/web/src/routes/CreateAccount.svelte
Jason Tudisco 0d7e48bed0 fix(kez-chat/web): blank page after login — redirect to /chats not /dashboard
Unlock (passphrase + biometric) and CreateAccount still pushed to
/dashboard, which the redesign removed from the routes map. svelte-spa-
router matched nothing and rendered a blank page. Point them at /chats
(the new home).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:17:36 -06:00

275 lines
8.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import {
generateIdentity,
signRegistration,
type Ed25519Identity,
} from "../lib/kez.js";
import { register, healthz, ApiError } from "../lib/api.js";
import { saveIdentity } from "../lib/identity-store.js";
import { session } from "../lib/store.svelte.js";
let step = $state<"handle" | "seed" | "confirm" | "submitting" | "done">("handle");
let handle = $state("");
let passphrase = $state("");
let passphrase2 = $state("");
let serverInfo = $state<{ server: string; version: string } | null>(null);
let id = $state<Ed25519Identity | null>(null);
let seedHex = $state("");
let seedAck = $state(false);
let error = $state<string | null>(null);
let working = $state(false);
onMount(async () => {
try {
const h = await healthz();
serverInfo = { server: h.server, version: h.version };
} catch {
serverInfo = null;
}
});
function validateHandleClient(h: string): string | null {
if (h.length < 3) return "Handle must be at least 3 characters.";
if (h.length > 32) return "Handle must be at most 32 characters.";
if (!/^[a-z0-9][a-z0-9_-]*$/.test(h))
return "Handle must be lowercase letters/digits/underscore/dash, starting with a letter or digit.";
return null;
}
function goToSeedStep() {
error = null;
const v = validateHandleClient(handle);
if (v) { error = v; return; }
if (passphrase.length < 8) { error = "Passphrase must be at least 8 characters."; return; }
if (passphrase !== passphrase2) { error = "Passphrases don't match."; return; }
id = generateIdentity();
seedHex = bytesToHex(id.seed);
step = "seed";
}
async function submitRegistration() {
if (!id || !serverInfo) return;
working = true;
error = null;
step = "submitting";
try {
const signed = signRegistration(id, handle, serverInfo.server);
const resp = await register(signed);
await saveIdentity({
handle: resp.handle,
server: serverInfo.server,
primary: id.identity,
seed: id.seed,
passphrase,
});
session.setUnlocked({
handle: resp.handle,
server: serverInfo.server,
primary: id.identity,
seed: id.seed,
});
step = "done";
} catch (e) {
step = "confirm";
if (e instanceof ApiError) {
error = `${e.code ?? "error"}: ${e.message}`;
} else {
error = (e as Error).message;
}
} finally {
working = false;
}
}
function copyToClipboard(s: string) {
navigator.clipboard.writeText(s).catch(() => {});
}
</script>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-text">Create account</h1>
{#if !serverInfo}
<p class="text-sm text-warning bg-warning/10 border border-warning/40 rounded p-3">
Couldn't reach the chat server. Try refreshing.
</p>
{/if}
<!-- Stepper -->
<ol class="flex gap-2 text-xs text-text-muted">
<li class={step === "handle" ? "font-semibold text-text" : ""}>1. Handle</li>
<li></li>
<li class={step === "seed" ? "font-semibold text-text" : ""}>2. Back up seed</li>
<li></li>
<li class={step === "confirm" || step === "submitting" ? "font-semibold text-text" : ""}>3. Confirm</li>
<li></li>
<li class={step === "done" ? "font-semibold text-text" : ""}>4. Done</li>
</ol>
{#if error}
<p class="text-sm text-danger bg-danger/10 border border-danger/40 rounded p-3">
{error}
</p>
{/if}
{#if step === "handle"}
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); goToSeedStep(); }}
>
<div>
<label class="block text-sm font-medium text-text-secondary" for="handle">
Handle
</label>
<div class="mt-1 flex items-stretch border border-border rounded-md overflow-hidden bg-surface">
<input
id="handle"
type="text"
bind:value={handle}
placeholder="tudisco"
class="flex-1 px-3 py-2 outline-none text-text"
autocomplete="off"
/>
{#if serverInfo}
<span class="px-3 py-2 text-text-muted bg-elevated border-l border-border">
@{serverInfo.server}
</span>
{/if}
</div>
<p class="mt-1 text-xs text-text-muted">
Lowercase letters, digits, <code>-</code>, <code>_</code>. 332 chars.
</p>
</div>
<div>
<label class="block text-sm font-medium text-text-secondary" for="pw">
Passphrase (encrypts your seed in this browser)
</label>
<input
id="pw"
type="password"
bind:value={passphrase}
class="mt-1 w-full px-3 py-2 border border-border rounded-md outline-none"
autocomplete="new-password"
/>
<input
type="password"
bind:value={passphrase2}
placeholder="confirm passphrase"
class="mt-2 w-full px-3 py-2 border border-border rounded-md outline-none"
autocomplete="new-password"
/>
<p class="mt-1 text-xs text-text-muted">
The seed itself is your real identity. The passphrase only
protects the local copy in this browser.
</p>
</div>
<button
type="submit"
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!serverInfo}
>
Continue
</button>
</form>
{/if}
{#if step === "seed" && id}
<div class="space-y-4">
<div class="border border-warning/40 bg-warning/10 rounded-lg p-4 space-y-3">
<p class="font-semibold text-warning">⚠️ Back up your seed now</p>
<p class="text-sm text-warning">
This is the only way to recover your account on another device
(or after clearing this browser). The server doesn't have it.
Write it down or paste into a password manager.
</p>
<div class="mt-3 p-3 bg-surface border border-warning/40 rounded font-mono text-sm break-all select-all">
{seedHex}
</div>
<div class="flex gap-2">
<button
class="text-xs px-3 py-1 bg-warning/10 text-accent-contrast rounded hover:bg-warning/20"
onclick={() => copyToClipboard(seedHex)}
>
Copy seed
</button>
</div>
</div>
<label class="flex items-start gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={seedAck} class="mt-1" />
I've saved this seed somewhere safe. I understand losing it means
losing my account permanently.
</label>
<div class="flex gap-2">
<button
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "handle"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={!seedAck}
onclick={() => { step = "confirm"; }}
>
I've saved it — continue
</button>
</div>
</div>
{/if}
{#if step === "confirm" && id}
<div class="space-y-4">
<p class="text-text-secondary">Ready to register:</p>
<div class="border border-border rounded-lg p-4 bg-elevated space-y-2 text-sm">
<div><span class="text-text-muted">Handle:</span> <span class="font-mono font-semibold">{handle}@{serverInfo?.server}</span></div>
<div><span class="text-text-muted">Public key:</span> <code class="break-all">{id.identity}</code></div>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 border border-border rounded-md text-text-secondary hover:bg-elevated"
onclick={() => { step = "seed"; }}
>
Back
</button>
<button
class="px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={working}
onclick={submitRegistration}
>
{working ? "Registering…" : "Register"}
</button>
</div>
</div>
{/if}
{#if step === "submitting"}
<p class="text-text-secondary">Submitting registration to {serverInfo?.server}</p>
{/if}
{#if step === "done"}
<div class="border border-verified/40 bg-verified/10 rounded-lg p-6">
<p class="text-lg font-semibold text-verified">✓ Account created</p>
<p class="mt-2 text-sm text-verified">
You are <span class="font-mono font-semibold">{handle}@{serverInfo?.server}</span>.
</p>
<button
class="mt-4 px-4 py-2 bg-accent text-accent-contrast rounded-md hover:bg-accent-dim"
onclick={() => push("/chats")}
>
Go to dashboard
</button>
</div>
{/if}
</div>