web: add account passkey login UI
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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