web: add account passkey login UI
This commit is contained in:
parent
7dda898830
commit
7740191194
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
102
web/workspace/src/lib/workspace/auth/api.ts
Normal file
102
web/workspace/src/lib/workspace/auth/api.ts
Normal 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>);
|
||||
}
|
||||
66
web/workspace/src/lib/workspace/auth/model.test.ts
Normal file
66
web/workspace/src/lib/workspace/auth/model.test.ts
Normal 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]);
|
||||
});
|
||||
142
web/workspace/src/lib/workspace/auth/model.ts
Normal file
142
web/workspace/src/lib/workspace/auth/model.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
160
web/workspace/src/routes/account/+page.svelte
Normal file
160
web/workspace/src/routes/account/+page.svelte
Normal 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>
|
||||
118
web/workspace/src/routes/login/device/+page.svelte
Normal file
118
web/workspace/src/routes/login/device/+page.svelte
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user