web: add account passkey login UI

This commit is contained in:
2026-07-23 03:14:43 +09:00
parent 7dda898830
commit 7740191194
12 changed files with 760 additions and 4 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
if (!workspaceId) {
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const publicRoutes = new Set(["/account", "/login/device"]);
if (workspace.data && !publicRoutes.has(url.pathname)) {
const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`);
}
@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadWhoami,
loginWithPasskey,
logout,
registerPasskey,
} from '$lib/workspace/auth/api';
import type { RequestActor } from '$lib/workspace/auth/model';
let actor = $state<RequestActor | null>(null);
let handle = $state('local');
let displayName = $state('Local User');
let loading = $state(true);
let busy = $state(false);
let status = $state('');
let error = $state('');
async function refreshWhoami() {
loading = true;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
if (actor?.user.handle) {
handle = actor.user.handle;
displayName = actor.user.display_name;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function register() {
busy = true;
status = 'Waiting for passkey registration…';
error = '';
try {
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
status = 'Passkey registered.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function login() {
busy = true;
status = 'Waiting for passkey login…';
error = '';
try {
await loginWithPasskey(handle.trim());
status = 'Logged in.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function signOut() {
busy = true;
error = '';
try {
await logout();
actor = null;
status = 'Logged out.';
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
busy = false;
}
}
onMount(() => {
void refreshWhoami();
});
</script>
<svelte:head>
<title>Account · Yoi Workspace</title>
</svelte:head>
<main class="settings-page">
<header class="settings-header">
<p class="eyebrow">Account</p>
<h1>Account</h1>
<p>Register a passkey, sign in, sign out, and inspect the current browser session.</p>
</header>
<section class="settings-panel account-panel">
<div>
<h2>Current user</h2>
{#if loading}
<p class="muted">Loading session…</p>
{:else if actor}
<dl class="account-details">
<div>
<dt>Handle</dt>
<dd>{actor.user.handle}</dd>
</div>
<div>
<dt>Display name</dt>
<dd>{actor.user.display_name}</dd>
</div>
<div>
<dt>User ID</dt>
<dd><code>{actor.user.user_id}</code></dd>
</div>
<div>
<dt>Account ID</dt>
<dd><code>{actor.user.account_id}</code></dd>
</div>
<div>
<dt>Auth kind</dt>
<dd>{actor.auth_kind}</dd>
</div>
</dl>
<div class="settings-action-row">
<button type="button" disabled={busy} onclick={signOut}>Log out</button>
<button class="secondary-button" type="button" disabled={busy} onclick={refreshWhoami}>Refresh</button>
</div>
{:else}
<p class="muted">No browser session is active.</p>
{/if}
</div>
</section>
<section class="settings-panel">
<h2>Passkey</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
<label>
User handle
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
</label>
<label>
Display name
<input bind:value={displayName} autocomplete="name" placeholder="Local User" />
</label>
<div class="settings-action-row">
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button>
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with passkey</button>
</div>
</form>
</section>
{#if status}
<p class="status-message">{status}</p>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</main>
@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
approveDeviceLogin,
loadWhoami,
loginWithPasskey,
} from '$lib/workspace/auth/api';
import type { RequestActor } from '$lib/workspace/auth/model';
let actor = $state<RequestActor | null>(null);
let handle = $state('local');
let userCode = $state('');
let loading = $state(true);
let busy = $state(false);
let status = $state('');
let error = $state('');
async function refreshWhoami() {
loading = true;
error = '';
try {
const whoami = await loadWhoami();
actor = whoami.actor;
if (actor?.user.handle) handle = actor.user.handle;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function login() {
busy = true;
status = 'Waiting for passkey login…';
error = '';
try {
await loginWithPasskey(handle.trim());
status = 'Logged in. You can approve the CLI login now.';
await refreshWhoami();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
async function approve() {
busy = true;
status = 'Approving device login…';
error = '';
try {
const result = await approveDeviceLogin(userCode.trim().toUpperCase());
status = result.status === 'approved'
? 'Device login approved. You can return to the CLI.'
: `Device login status: ${result.status}`;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
status = '';
} finally {
busy = false;
}
}
onMount(() => {
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
void refreshWhoami();
});
</script>
<svelte:head>
<title>Device Login · Yoi Workspace</title>
</svelte:head>
<main class="settings-page">
<header class="settings-header">
<p class="eyebrow">Device Login</p>
<h1>Approve CLI login</h1>
<p>Use your browser session to approve a pending CLI/TUI login request.</p>
</header>
<section class="settings-panel">
<h2>Browser session</h2>
{#if loading}
<p class="muted">Loading session…</p>
{:else if actor}
<p>Logged in as <strong>{actor.user.display_name}</strong> <span class="muted">@{actor.user.handle}</span>.</p>
{:else}
<p class="muted">You need to log in with a passkey before approving a device login.</p>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}>
<label>
User handle
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
</label>
<button type="submit" disabled={busy || !handle.trim()}>Log in with passkey</button>
</form>
{/if}
</section>
<section class="settings-panel">
<h2>Approval code</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void approve(); }}>
<label>
User code
<input bind:value={userCode} placeholder="ABCD-EFGH" autocapitalize="characters" />
</label>
<button type="submit" disabled={busy || !actor || !userCode.trim()}>Approve device login</button>
</form>
</section>
{#if status}
<p class="status-message">{status}</p>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</main>