web: add account passkey login UI

This commit is contained in:
Keisuke Hirata 2026-07-23 03:14:43 +09:00
parent 7dda898830
commit 7740191194
No known key found for this signature in database
12 changed files with 760 additions and 4 deletions

View File

@ -38,8 +38,8 @@ use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeE
use crate::auth::{
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id,
new_user_code, normalize_handle, resolve_request_actor, rfc3339_after, session_set_cookie,
token_hash,
new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after,
session_set_cookie, token_hash,
};
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@ -337,6 +337,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/auth/passkeys/login/complete",
post(post_passkey_login_complete),
)
.route("/api/auth/logout", post(post_auth_logout))
.route("/api/auth/device-login/start", post(post_device_login_start))
.route("/api/auth/device-login/approve", post(post_device_login_approve))
.route("/api/auth/device-login/poll", post(post_device_login_poll))
@ -2595,6 +2596,11 @@ struct WhoamiResponse {
actor: Option<RequestActor>,
}
#[derive(Debug, Serialize, Deserialize)]
struct LogoutResponse {
status: String,
}
async fn get_auth_config(State(api): State<WorkspaceApi>) -> ApiResult<Json<AuthPublicConfig>> {
Ok(Json(auth_public_config(&api.config)))
}
@ -3023,6 +3029,37 @@ async fn get_auth_whoami(
}))
}
async fn post_auth_logout(
State(api): State<WorkspaceApi>,
headers: HeaderMap,
) -> ApiResult<Response> {
let auth = auth_public_config(&api.config);
if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) {
let _ = api
.store
.revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?;
}
let mut response_headers = HeaderMap::new();
response_headers.insert(
SET_COOKIE,
session_set_cookie(&auth.cookie_name, "", 0)
.parse()
.map_err(|error| {
auth_error(
"invalid_session_cookie",
&format!("failed to build logout cookie: {error}"),
)
})?,
);
Ok((
response_headers,
Json(LogoutResponse {
status: "logged_out".to_string(),
}),
)
.into_response())
}
fn webauthn(config: &ServerConfig) -> ApiResult<Webauthn> {
let auth = auth_public_config(config);
let origin = Url::parse(&auth.origin)

View File

@ -243,6 +243,7 @@ pub trait ControlPlaneStore: Send + Sync {
) -> Result<Option<AuthChallengeRecord>>;
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()>;
fn resolve_browser_session(&self, token_hash: &str) -> Result<Option<BrowserSessionRecord>>;
fn revoke_browser_session(&self, token_hash: &str, revoked_at: &str) -> Result<bool>;
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>>;
fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>;
@ -621,6 +622,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn revoke_browser_session(&self, token_hash: &str, revoked_at: &str) -> Result<bool> {
self.with_conn(|conn| {
let changed = conn.execute(
"UPDATE browser_sessions SET revoked_at = ?2 WHERE token_hash = ?1 AND revoked_at IS NULL",
params![token_hash, revoked_at],
)?;
Ok(changed > 0)
})
}
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
@ -2455,6 +2466,31 @@ mod tests {
None
);
let browser_session = BrowserSessionRecord {
session_id: "session-id".to_string(),
token_hash: "session-hash".to_string(),
user_id: user.user_id.clone(),
created_at: now.clone(),
expires_at: "2026-07-22T12:00:00Z".to_string(),
revoked_at: None,
};
store.create_browser_session(&browser_session).unwrap();
assert_eq!(
store.resolve_browser_session("session-hash").unwrap(),
Some(browser_session)
);
assert!(
store
.revoke_browser_session("session-hash", "2026-07-22T00:02:00Z")
.unwrap()
);
assert_eq!(store.resolve_browser_session("session-hash").unwrap(), None);
assert!(
!store
.revoke_browser_session("session-hash", "2026-07-22T00:03:00Z")
.unwrap()
);
let api_token = ApiTokenRecord {
token_id: "token-id".to_string(),
token_hash: "hash".to_string(),

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -872,6 +872,45 @@
color: var(--text-muted);
}
.error-message {
margin: 0;
color: var(--danger);
}
.account-details {
display: grid;
gap: var(--space-2);
margin: var(--space-3) 0;
}
.account-details div {
display: grid;
grid-template-columns: minmax(8rem, 0.35fr) 1fr;
gap: var(--space-3);
align-items: baseline;
}
.account-details dt {
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.account-details dd {
margin: 0;
color: var(--text-strong);
}
.account-details code {
font-size: 0.78rem;
}
.account-panel {
display: grid;
gap: var(--space-4);
}
@media (max-width: 760px) {
.settings-hero,
.settings-notice,

View File

@ -0,0 +1,102 @@
import {
authenticationCredentialToJson,
isPublicKeyCredential,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type DeviceApprovalResponse,
type PasskeyLoginOptionsResponse,
type PasskeyRegistrationOptionsResponse,
type PasskeyUserResponse,
type WhoamiResponse,
} from "./model";
async function jsonOrThrow<T>(response: Response): Promise<T> {
const text = await response.text();
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`);
}
return text ? JSON.parse(text) as T : (null as T);
}
export async function loadWhoami(fetcher: typeof fetch = fetch): Promise<WhoamiResponse> {
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow<WhoamiResponse>);
}
export async function registerPasskey(
handle: string,
displayName: string,
fetcher: typeof fetch = fetch,
): Promise<PasskeyUserResponse> {
const options = await fetcher("/api/auth/passkeys/registration/options", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ handle, display_name: displayName }),
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
const credential = await navigator.credentials.create({
publicKey: prepareRegistrationOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey registration did not return a public-key credential.");
}
return await fetcher("/api/auth/passkeys/registration/complete", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
challenge_id: options.challenge_id,
credential: registrationCredentialToJson(credential),
}),
}).then(jsonOrThrow<PasskeyUserResponse>);
}
export async function loginWithPasskey(
handle: string,
fetcher: typeof fetch = fetch,
): Promise<PasskeyUserResponse> {
const options = await fetcher("/api/auth/passkeys/login/options", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ handle }),
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
const credential = await navigator.credentials.get({
publicKey: prepareLoginOptions(options),
});
if (!isPublicKeyCredential(credential)) {
throw new Error("Passkey login did not return a public-key credential.");
}
return await fetcher("/api/auth/passkeys/login/complete", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
challenge_id: options.challenge_id,
credential: authenticationCredentialToJson(credential),
}),
}).then(jsonOrThrow<PasskeyUserResponse>);
}
export async function logout(fetcher: typeof fetch = fetch): Promise<void> {
await fetcher("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
}).then(jsonOrThrow<unknown>);
}
export async function approveDeviceLogin(
userCode: string,
fetcher: typeof fetch = fetch,
): Promise<DeviceApprovalResponse> {
return await fetcher("/api/auth/device-login/approve", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ user_code: userCode }),
}).then(jsonOrThrow<DeviceApprovalResponse>);
}

View File

@ -0,0 +1,66 @@
import {
base64UrlToBuffer,
bufferToBase64Url,
prepareLoginOptions,
prepareRegistrationOptions,
} from "./model.ts";
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function bytes(buffer: BufferSource): number[] {
if (buffer instanceof ArrayBuffer) {
return [...new Uint8Array(buffer)];
}
return [...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)];
}
Deno.test("base64url helpers round-trip binary data", () => {
const source = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]).buffer;
const encoded = bufferToBase64Url(source);
assertEquals(encoded.includes("+"), false);
assertEquals(encoded.includes("/"), false);
assertEquals(encoded.includes("="), false);
assertEquals(bytes(base64UrlToBuffer(encoded)), bytes(source));
});
Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
const options = prepareRegistrationOptions({
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
rp: { id: "localhost", name: "Yoi" },
user: {
id: "BAUG" as unknown as BufferSource,
name: "local",
displayName: "Local User",
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
},
});
assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.user.id), [4, 5, 6]);
assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [7, 8, 9]);
});
Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => {
const options = prepareLoginOptions({
challenge_id: "challenge-1",
public_key: {
challenge: "AQID" as unknown as BufferSource,
allowCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }],
},
});
assertEquals(bytes(options.challenge), [1, 2, 3]);
assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [7, 8, 9]);
});

View File

@ -0,0 +1,142 @@
export type AuthenticatedUser = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
};
export type RequestActor = {
user: AuthenticatedUser;
auth_kind: string;
};
export type WhoamiResponse = {
actor: RequestActor | null;
};
export type PasskeyRegistrationOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialCreationOptions | { publicKey: PublicKeyCredentialCreationOptions };
};
export type PasskeyLoginOptionsResponse = {
challenge_id: string;
public_key: PublicKeyCredentialRequestOptions | { publicKey: PublicKeyCredentialRequestOptions };
};
export type PasskeyUserResponse = {
user: AuthenticatedUser;
};
export type DeviceApprovalResponse = {
status: string;
};
export type RegistrationCredentialJson = {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
attestationObject: string;
transports: string[];
};
};
export type AuthenticationCredentialJson = {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
};
export function base64UrlToBuffer(value: string): ArrayBuffer {
const padding = "=".repeat((4 - (value.length % 4)) % 4);
const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer;
}
export function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
if (value && typeof value === "object" && "publicKey" in value) {
return (value as { publicKey: T }).publicKey;
}
return value as T;
}
export function prepareRegistrationOptions(
options: PasskeyRegistrationOptionsResponse,
): PublicKeyCredentialCreationOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
publicKey.user = {
...publicKey.user,
id: base64UrlToBuffer(publicKey.user.id as unknown as string),
};
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((credential) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
return publicKey;
}
export function prepareLoginOptions(
options: PasskeyLoginOptionsResponse,
): PublicKeyCredentialRequestOptions {
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string);
publicKey.allowCredentials = publicKey.allowCredentials?.map((credential) => ({
...credential,
id: base64UrlToBuffer(credential.id as unknown as string),
}));
return publicKey;
}
export function registrationCredentialToJson(
credential: PublicKeyCredential,
): RegistrationCredentialJson {
const response = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
transports: response.getTransports?.() ?? [],
},
};
}
export function authenticationCredentialToJson(
credential: PublicKeyCredential,
): AuthenticationCredentialJson {
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
},
};
}
export function isPublicKeyCredential(credential: Credential | null): credential is PublicKeyCredential {
return credential != null && credential.type === "public-key";
}

View File

@ -373,3 +373,46 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
"target-change effect should load data without depending on manual refresh state reads",
);
});
Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => {
const accountPage = await Deno.readTextFile(
new URL("./../../../routes/account/+page.svelte", import.meta.url),
);
const devicePage = await Deno.readTextFile(
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
);
const authApi = await Deno.readTextFile(
new URL("../auth/api.ts", import.meta.url),
);
const rootLayout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
accountPage.includes("registerPasskey") &&
accountPage.includes("loginWithPasskey") &&
accountPage.includes("logout") &&
accountPage.includes("loadWhoami") &&
accountPage.includes("Current user"),
"Account page should expose registration, login, logout, and current-session inspection",
);
assert(
devicePage.includes("approveDeviceLogin") &&
devicePage.includes("loginWithPasskey") &&
devicePage.includes("user_code") &&
devicePage.includes("Approve device login"),
"Device login page should approve CLI login without DevTools console",
);
assert(
authApi.includes("/api/auth/whoami") &&
authApi.includes("/api/auth/passkeys/registration/options") &&
authApi.includes("/api/auth/passkeys/login/complete") &&
authApi.includes("/api/auth/logout") &&
authApi.includes("/api/auth/device-login/approve"),
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
);
assert(
rootLayout.includes('"/account"') && rootLayout.includes('"/login/device"'),
"Root layout should not redirect account and device-login public routes to a workspace",
);
});

View File

@ -28,6 +28,7 @@
let workspaceId = $derived(workspace?.workspace_id ?? '');
let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
let accountHref = '/account';
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script>
@ -87,6 +88,17 @@
/>
</svg>
</a>
<a
class="sidebar-icon-button"
href={accountHref}
aria-label="Open Account"
title="Account"
>
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
<a
class="sidebar-icon-button"
href={settingsHref}

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}`);
}

View File

@ -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>

View File

@ -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>