diff --git a/tools/web-ux/scenarios/public-entry-dark.json b/tools/web-ux/scenarios/public-entry-dark.json new file mode 100644 index 000000000..4ccc005e0 --- /dev/null +++ b/tools/web-ux/scenarios/public-entry-dark.json @@ -0,0 +1,372 @@ +{ + "schemaVersion": 1, + "id": "public-entry-dark", + "title": "Public entry and authenticated Workspace selection", + "baseUrl": "http://127.0.0.1:15413", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection; not a real authentication session", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 900 + } + ], + "routes": [ + { + "id": "anonymous", + "label": "anonymous", + "path": "/?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; policy allowed; Workspace API would reject", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-disabled", + "label": "policy-disabled", + "path": "/?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Policy disabled; login retained", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-error", + "label": "policy-error", + "path": "/?fixture=policy-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional public policy HTTP503; login retained, no registration link", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"availability is unknown\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-loading", + "label": "policy-loading", + "path": "/?fixture=policy-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Public policy held pending; login retained", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Checking registration\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-loading", + "label": "session-loading", + "path": "/?fixture=session-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Whoami held pending; no anonymous or actor actions", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading session\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-error", + "label": "session-error", + "path": "/?fixture=session-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional whoami HTTP503; unknown, not logged out", + "ready": { + "kind": "selector", + "selector": "[role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "account", + "label": "account", + "path": "/?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed ordinary actor; no Workspace fetch or automatic navigation", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "admin", + "label": "admin", + "path": "/?fixture=admin", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed hosting admin grant independent of Workspace ownership", + "ready": { + "kind": "selector", + "selector": "a[href=\"/hosting/settings\"]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-zero", + "label": "dashboard-zero", + "path": "/dashboard?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed actor; zero Workspaces", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-one", + "label": "dashboard-one", + "path": "/dashboard?fixture=workspace-one", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "One Workspace requires explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-many", + "label": "dashboard-many", + "path": "/dashboard?fixture=workspace-many", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Three Workspaces, long display name, explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card:nth-child(3)" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-error", + "label": "dashboard-error", + "path": "/dashboard?fixture=workspace-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional catalog HTTP503; not empty; account retained", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-loading", + "label": "dashboard-loading", + "path": "/dashboard?fixture=workspace-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Catalog held pending; account remains available", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading Workspaces\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-anonymous", + "label": "dashboard-anonymous", + "path": "/dashboard?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; login returnTo dashboard, no catalog fetch", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "create-denied", + "label": "create-denied", + "path": "/dashboard?fixture=creation-denied#workspace-create-title", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional creation HTTP403; exact retry, no resources created", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Creation denied", + "fullPage": true, + "interaction": [ + { + "action": "fill", + "selector": "input[autocomplete=off]", + "value": "New Workspace" + }, + { + "action": "click", + "selector": "button[type=submit]" + } + ], + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + } + } + ] + }, + { + "id": "login-disabled", + "label": "login-disabled", + "path": "/login?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Login remains usable while registration is disabled", + "ready": { + "kind": "selector", + "selector": "nav p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "workspace-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--allow-read", + "--allow-net", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + "15414", + "web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15414/health" + }, + { + "id": "entry-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15413", + "web/workspace/build", + "http://127.0.0.1:15414" + ], + "readyUrl": "http://127.0.0.1:15413/health" + } + ] +} diff --git a/tools/web-ux/scenarios/public-entry-light.json b/tools/web-ux/scenarios/public-entry-light.json new file mode 100644 index 000000000..c3dda910d --- /dev/null +++ b/tools/web-ux/scenarios/public-entry-light.json @@ -0,0 +1,372 @@ +{ + "schemaVersion": 1, + "id": "public-entry-light", + "title": "Public entry and authenticated Workspace selection", + "baseUrl": "http://127.0.0.1:15413", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection; not a real authentication session", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 900 + } + ], + "routes": [ + { + "id": "anonymous", + "label": "anonymous", + "path": "/?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; policy allowed; Workspace API would reject", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-disabled", + "label": "policy-disabled", + "path": "/?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Policy disabled; login retained", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-error", + "label": "policy-error", + "path": "/?fixture=policy-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional public policy HTTP503; login retained, no registration link", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"availability is unknown\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-loading", + "label": "policy-loading", + "path": "/?fixture=policy-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Public policy held pending; login retained", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Checking registration\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-loading", + "label": "session-loading", + "path": "/?fixture=session-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Whoami held pending; no anonymous or actor actions", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading session\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-error", + "label": "session-error", + "path": "/?fixture=session-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional whoami HTTP503; unknown, not logged out", + "ready": { + "kind": "selector", + "selector": "[role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "account", + "label": "account", + "path": "/?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed ordinary actor; no Workspace fetch or automatic navigation", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "admin", + "label": "admin", + "path": "/?fixture=admin", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed hosting admin grant independent of Workspace ownership", + "ready": { + "kind": "selector", + "selector": "a[href=\"/hosting/settings\"]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-zero", + "label": "dashboard-zero", + "path": "/dashboard?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed actor; zero Workspaces", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-one", + "label": "dashboard-one", + "path": "/dashboard?fixture=workspace-one", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "One Workspace requires explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-many", + "label": "dashboard-many", + "path": "/dashboard?fixture=workspace-many", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Three Workspaces, long display name, explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card:nth-child(3)" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-error", + "label": "dashboard-error", + "path": "/dashboard?fixture=workspace-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional catalog HTTP503; not empty; account retained", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-loading", + "label": "dashboard-loading", + "path": "/dashboard?fixture=workspace-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Catalog held pending; account remains available", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading Workspaces\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-anonymous", + "label": "dashboard-anonymous", + "path": "/dashboard?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; login returnTo dashboard, no catalog fetch", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "create-denied", + "label": "create-denied", + "path": "/dashboard?fixture=creation-denied#workspace-create-title", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional creation HTTP403; exact retry, no resources created", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Creation denied", + "fullPage": true, + "interaction": [ + { + "action": "fill", + "selector": "input[autocomplete=off]", + "value": "New Workspace" + }, + { + "action": "click", + "selector": "button[type=submit]" + } + ], + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + } + } + ] + }, + { + "id": "login-disabled", + "label": "login-disabled", + "path": "/login?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Login remains usable while registration is disabled", + "ready": { + "kind": "selector", + "selector": "nav p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "workspace-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--allow-read", + "--allow-net", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + "15414", + "web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15414/health" + }, + { + "id": "entry-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15413", + "web/workspace/build", + "http://127.0.0.1:15414" + ], + "readyUrl": "http://127.0.0.1:15413/health" + } + ] +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index d7623e425..b98886d97 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -19,7 +19,8 @@ "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check", - "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts" + "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts", + "test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts" }, "imports": { "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0", diff --git a/web/workspace/src/lib/workspace/auth/AccountNavigation.svelte b/web/workspace/src/lib/workspace/auth/AccountNavigation.svelte new file mode 100644 index 000000000..38b1fedd7 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/AccountNavigation.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index cac9c86f0..8d4946211 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -5,7 +5,8 @@ import { loginWithPasskey, registerPasskey } from './api.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; - import { authEntryUrl, safeReturnTo } from './routes.ts'; + import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; + import RegistrationLink from './RegistrationLink.svelte'; import './auth-page.css'; let { mode }: { mode: 'login' | 'register' } = $props(); @@ -72,7 +73,7 @@ {:else if auth.actor}
Logged in as {auth.actor.display_name} @{auth.actor.handle}.
- Continue to {destination === '/' ? 'dashboard / Workspaces' : 'requested page'} + Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'} Open Account {:else} {#if mode === 'register'} @@ -103,12 +104,13 @@ {#if auth.status}{auth.status}
{/if} {#if auth.error}{auth.error}
{/if} diff --git a/web/workspace/src/lib/workspace/auth/RegistrationLink.svelte b/web/workspace/src/lib/workspace/auth/RegistrationLink.svelte new file mode 100644 index 000000000..0665e7795 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/RegistrationLink.svelte @@ -0,0 +1,40 @@ + + +{#if policy === 'enabled'} + Create a new account +{:else if policy === 'disabled'} +New account registration is disabled. Existing users can still log in.
+{:else if policy === 'loading'} +Checking registration availability…
+{:else} +Registration availability is unknown. Existing users can still log in.
+ +{/if} diff --git a/web/workspace/src/lib/workspace/auth/auth-page.css b/web/workspace/src/lib/workspace/auth/auth-page.css index c857db5f8..5540693c0 100644 --- a/web/workspace/src/lib/workspace/auth/auth-page.css +++ b/web/workspace/src/lib/workspace/auth/auth-page.css @@ -18,4 +18,7 @@ .auth-page dl { display: grid; gap: var(--space-4); margin: 0; } .auth-page dl div { display: grid; gap: var(--space-1); min-width: 0; } .auth-page dt { color: var(--text-muted); } +.auth-page a.entry-primary { justify-self: start; padding: var(--space-2) var(--space-4); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--text-strong); color: var(--bg); text-decoration: none; } +.auth-page a.entry-primary:hover { background: var(--interactive-hover); color: var(--text-strong); } +.auth-page a.entry-primary:active { background: var(--interactive-selected); color: var(--text-strong); } .auth-page dd { margin: 0; overflow-wrap: anywhere; } diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts index 79607ed72..c80b69926 100644 --- a/web/workspace/src/lib/workspace/auth/routes.test.ts +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -15,7 +15,8 @@ import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { for ( const [input, expected] of [ - ["/", "/"], + ["/", "/dashboard"], + ["/dashboard?query=discard#fragment", "/dashboard"], ["/account", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"], [ @@ -68,7 +69,7 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic "/not-an-app-route", "/" + "a".repeat(2049), ] - ) assertEquals(safeReturnTo(input), "/", String(input)); + ) assertEquals(safeReturnTo(input), "/dashboard", String(input)); }); Deno.test("login links encode one validated destination and discard nested auth loops", () => { @@ -80,5 +81,8 @@ Deno.test("login links encode one validated destination and discard nested auth authEntryUrl("/register", "/account"), "/register?returnTo=%2Faccount", ); - assertEquals(loginUrl("/login?returnTo=/login"), "/login?returnTo=%2F"); + assertEquals( + loginUrl("/login?returnTo=/login"), + "/login?returnTo=%2Fdashboard", + ); }); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts index d6c9c2d50..595f8d44e 100644 --- a/web/workspace/src/lib/workspace/auth/routes.ts +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -1,6 +1,6 @@ -// T760が公開トップ/Workspace選択を整理するまで、現行の一覧をdashboardへの入口とする。 +// 公開トップではなく明示的なWorkspace選択へ戻す。先頭Workspaceの推測はしない。 // 戻り先は既知のアプリ画面だけ。認証経路への再帰やdecode段階の差による別originを作らない。 -export const AUTHENTICATED_HOME = "/"; +export const AUTHENTICATED_HOME = "/dashboard"; export function safeReturnTo(value: string | null): string { if ( @@ -32,7 +32,7 @@ export function safeReturnTo(value: string | null): string { return "/login/device"; } if ( - url.pathname === "/" || url.pathname === "/account" || + url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) ) { return url.pathname; diff --git a/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte b/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte index c65515793..059c3ae44 100644 --- a/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte +++ b/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte @@ -51,7 +51,8 @@ }: Props = $props(); const globalItems = [ - { href: "/#workspace-create-title", label: "Create Workspace" }, + { href: "/dashboard", label: "Dashboard / Workspaces" }, + { href: "/dashboard#workspace-create-title", label: "Create Workspace" }, { href: "/account", label: "Account" }, { href: "/login/device", label: "Device Login" }, ]; diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte index 8c5cf1d97..a9f682926 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte @@ -173,7 +173,7 @@ Workspaces span { - display: inline-flex; - align-items: center; - gap: var(--space-1); - color: var(--text-muted); - font-size: var(--font-size-compact); - text-transform: uppercase; - } - - .workspace-card-heading > span::before { - width: 0.45rem; - height: 0.45rem; - border-radius: 50%; - background: currentColor; - content: ""; - } - - .workspace-card-heading > .workspace-state-active { - border-color: color-mix(in srgb, var(--success) 50%, var(--line)); - color: var(--success); - } - - .workspace-repository-summary { - display: grid; - gap: 0.2rem; - } - - .workspace-empty-state { - padding: 1.5rem; - margin-top: 1rem; - } - - .workspace-create-panel { - padding: 1.5rem; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); - gap: 2rem; - } - - .workspace-create-panel form { - display: grid; - gap: 1rem; - } - - .workspace-create-panel label { - display: grid; - gap: 0.35rem; - color: var(--text-muted); - font-size: var(--font-size-body); - } - - .workspace-create-panel input, - .workspace-switcher select { - min-width: 0; - border: 1px solid var(--line); - border-radius: 0.45rem; - background: var(--bg); - color: var(--text); - padding: 0.65rem 0.75rem; - font: inherit; - } - - .workspace-create-panel .workspace-repository-toggle { - display: flex; - align-items: center; - gap: var(--space-2); - color: var(--text); - } - - .workspace-repository-fields { - display: grid; - gap: var(--space-4); - min-width: 0; - } - - .workspace-create-panel input:not([type="checkbox"]) { - width: 100%; - } - - .workspace-create-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); - } - - .workspace-create-panel label { - min-width: 0; - } - - .workspace-create-row > label { - flex: 1; - } - - .workspace-primary-action, - .workspace-secondary-action { - border-radius: 0.45rem; - padding: 0.65rem 0.9rem; - font: inherit; - font-weight: 600; - cursor: pointer; - } - - .workspace-primary-action { - border: 1px solid var(--accent); - background: var(--accent); - color: var(--bg); - } - - .workspace-secondary-action { - border: 1px solid var(--line); - background: var(--bg-raised); - color: var(--text); - } - - .workspace-primary-action:disabled, - .workspace-secondary-action:disabled { - opacity: 0.55; - cursor: wait; - } - - .workspace-catalog-alert { - border-left: 3px solid var(--danger); - background: color-mix(in srgb, var(--danger) 8%, transparent); - padding: 0.75rem 0.9rem; - color: var(--text); - } - - .workspace-switcher { - display: grid; - gap: 0.45rem; - padding: 0 0.75rem 0.85rem; - border-bottom: 1px solid var(--line); - } - - .workspace-switcher label { - font-size: var(--font-size-compact); - letter-spacing: 0.08em; - color: var(--text-muted); - text-transform: uppercase; - } - - .workspace-switcher select { - width: 100%; - padding: 0.45rem 0.55rem; - font-size: var(--font-size-compact); - } - - .workspace-switcher-actions { - display: flex; - gap: 0.75rem; - font-size: var(--font-size-compact); - } - - .workspace-switcher-error { - color: var(--danger); - font-size: var(--font-size-compact); - } - - @media (max-width: 760px) { - .workspace-create-panel { - grid-template-columns: 1fr; - } - - .workspace-create-row, - .workspace-catalog-heading { - align-items: stretch; - flex-direction: column; - } - } -} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 9b5976801..a898e76e3 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -27,8 +27,8 @@ let { children, data }: LayoutProps = $props(); import { isAuthPage } from '#lib/workspace/auth/routes.ts'; - // 認証画面はWorkspaceの存在/取得/Sidebarに依存しない。 - const standalone = $derived(isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/')); + // 公開入口・認証・Workspace選択はWorkspace catalogやSidebarに依存しない。 + const standalone = $derived(page.url.pathname === '/' || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/')); const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog'); let sidebar = $stateAIとともに、開発を前へ。
+Ticketで目的を共有し、AI Workerと実装を進める。Workspaceでコード・レビュー・作業の記録をまとめます。
+Loading session…
+ {:else if auth.session.kind === 'error'} +Your session could not be loaded. Login status is unknown.
+ + Open login + {:else if auth.actor} +Logged in as {auth.actor.display_name}.
+ Open dashboard + {#key auth.actor.account_id}{auth.status}
{/if} {#if auth.error}{auth.error}
{/if} - Dashboard / Workspaces + Dashboard / Workspaces diff --git a/web/workspace/src/routes/account/account.browser.spec.ts b/web/workspace/src/routes/account/account.browser.spec.ts index d9cc685bf..d0a524886 100644 --- a/web/workspace/src/routes/account/account.browser.spec.ts +++ b/web/workspace/src/routes/account/account.browser.spec.ts @@ -141,7 +141,7 @@ async function submit(name: string) { } test("login asks only for an account ID and links separate registration and code activation", async () => { - const { fetcher } = boundary({ policy: false }); + const { fetcher } = boundary({ policy: true }); page.url = new URL("https://example.test/login?returnTo=%2Faccount"); render(Login); const handle = await screen.findByLabelText("Account ID (handle)"); @@ -149,9 +149,10 @@ test("login asks only for an account ID and links separate registration and code expect(screen.queryByRole("button", { name: "Register new account" })) .toBeNull(); expect( - screen.getByRole("link", { name: "Create a new account" }).getAttribute( - "href", - ), + (await screen.findByRole("link", { name: "Create a new account" })) + .getAttribute( + "href", + ), ) .toBe("/register?returnTo=%2Faccount"); expect( @@ -168,25 +169,25 @@ test("login asks only for an account ID and links separate registration and code .toBe(true); await submit("Log in with passkey"); expect(posted(fetcher)).toEqual([]); - expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy"); + expect(apiPaths(fetcher)).toContain("/api/auth/registration-policy"); }); for ( const [returnTo, expected] of [ - [null, "/"], + [null, "/dashboard"], ["/account", "/account"], ["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"], [ "/login/device?code=abcd-EFGH&untrusted=1", "/login/device?user_code=abcd-EFGH", ], - ["https://evil.test/", "/"], - ["//evil.test/account", "/"], - ["/\\evil.test/account", "/"], - ["/%2f%2fevil.test", "/"], - ["/login?returnTo=/login", "/"], - ["/register", "/"], - ["/activate", "/"], + ["https://evil.test/", "/dashboard"], + ["//evil.test/account", "/dashboard"], + ["/\\evil.test/account", "/dashboard"], + ["/%2f%2fevil.test", "/dashboard"], + ["/login?returnTo=/login", "/dashboard"], + ["/register", "/dashboard"], + ["/activate", "/dashboard"], ] as const ) { test(`login confirms whoami before navigating safely for returnTo=${returnTo}`, async () => { @@ -258,7 +259,7 @@ test("register requires a separate ID and display name and uses only new-account }); await submit("Register new account"); await waitFor(() => - expect(goto).toHaveBeenCalledWith("/", { replaceState: true }) + expect(goto).toHaveBeenCalledWith("/dashboard", { replaceState: true }) ); const requests = posted(fetcher); expect(requests.map(({ path }) => path)).toEqual([ @@ -301,7 +302,7 @@ for (const policy of [false, "error", "loading"] as const) { screen.getByRole("link", { name: "Log in with an existing account" }) .getAttribute("href"), ) - .toBe("/login?returnTo=%2F"); + .toBe("/login?returnTo=%2Fdashboard"); expect( screen.getByRole("link", { name: "Activate an account with a code" }) .getAttribute("href"), diff --git a/web/workspace/src/routes/dashboard/+page.svelte b/web/workspace/src/routes/dashboard/+page.svelte new file mode 100644 index 000000000..5c0917c53 --- /dev/null +++ b/web/workspace/src/routes/dashboard/+page.svelte @@ -0,0 +1,44 @@ + + +Loading session…
+ {:else if auth.session.kind === 'error'} +Your session could not be loaded. Login status is unknown.
+ + Open login + {:else if auth.actor} + {#key auth.actor.account_id} +Log in to select or create a Workspace.
+ Log in + {/if} +Backend
-Select an accessible team space or create one on this Backend.
+Choose a Workspace to open its dashboard. Workspace settings are managed inside each Workspace.