web: add account passkey login UI
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user