New-account success screen sent users straight to /chats; first-run users never saw the Getting Started checklist. Route to /welcome instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
275 lines
8.9 KiB
Svelte
275 lines
8.9 KiB
Svelte
<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>. 3–32 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("/welcome")}
|
||
>
|
||
Get started →
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|