feat: add public entry and authenticated workspace dashboard

This commit is contained in:
2026-10-16 10:48:51 +09:00
parent b8b12442a0
commit a060ffada8
32 changed files with 1702 additions and 368 deletions
@@ -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"
}
]
}
@@ -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"
}
]
}
+2 -1
View File
@@ -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",
@@ -0,0 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { loadHostingAccess } from '#lib/workspace/auth/hosting.ts';
let isAdmin = $state(false);
// 親の確認済みsessionが変わるとこのcomponentも破棄する。Workspace所有から管理権限を推測しない。
onMount(() => {
let disposed = false;
void loadHostingAccess().then((access) => { if (!disposed) isAdmin = access.is_admin; }).catch(() => {});
return () => { disposed = true; };
});
</script>
<nav aria-label="Your account">
<a href="/account">Account</a>
{#if isAdmin}<a href="/hosting/settings">Hosting settings</a>{/if}
</nav>
@@ -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 @@
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
{:else if auth.actor}
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
<a href={destination}>Continue to {destination === '/' ? 'dashboard / Workspaces' : 'requested page'}</a>
<a href={destination}>Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a>
<a href="/account">Open Account</a>
{:else}
{#if mode === 'register'}
@@ -103,12 +104,13 @@
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<nav aria-label="Authentication">
{#if mode === 'login'}
<a href={authEntryUrl('/register', destination)}>Create a new account</a>
{:else}
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
<RegistrationLink returnTo={destination} />
{:else if mode === 'register'}
<a href={authEntryUrl('/login', destination)}>Log in with an existing account</a>
{/if}
<a href="/activate">Activate an account with a code</a>
<a href="/">Dashboard / Workspaces</a>
<a href={AUTHENTICATED_HOME}>Dashboard / Workspaces</a>
<a href="/">Yoi home</a>
</nav>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import { onMount } from 'svelte';
import { loadRegistrationPolicy, SESSION_CHANGED_EVENT } from './hosting.ts';
import { authEntryUrl, AUTHENTICATED_HOME } from './routes.ts';
let { returnTo = AUTHENTICATED_HOME }: { returnTo?: string } = $props();
let policy = $state<'loading' | 'error' | 'enabled' | 'disabled'>('loading');
let request = 0;
async function refresh() {
const current = ++request;
policy = 'loading';
try {
const result = await loadRegistrationPolicy();
if (current === request) policy = result.self_registration_enabled ? 'enabled' : 'disabled';
} catch {
if (current === request) policy = 'error';
}
}
onMount(() => {
void refresh();
window.addEventListener('focus', refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => {
++request;
window.removeEventListener('focus', refresh);
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
};
});
</script>
{#if policy === 'enabled'}
<a href={authEntryUrl('/register', returnTo)}>Create a new account</a>
{:else if policy === 'disabled'}
<p class="muted">New account registration is disabled. Existing users can still log in.</p>
{:else if policy === 'loading'}
<p class="muted" role="status">Checking registration availability…</p>
{:else}
<p class="muted">Registration availability is unknown. Existing users can still log in.</p>
<button type="button" onclick={refresh}>Retry registration availability</button>
{/if}
@@ -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; }
@@ -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",
);
});
@@ -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;
@@ -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" },
];
@@ -173,7 +173,7 @@
<span>Workspaces</span>
<a
class="workspace-menu-add"
href="/#workspace-create-title"
href="/dashboard#workspace-create-title"
role="menuitem"
aria-label="Create Workspace"
title="Create Workspace"
@@ -1,245 +0,0 @@
@layer reset, tokens, base, layout, components;
@layer components {
.workspace-catalog-shell {
width: min(1120px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
display: grid;
gap: 2.5rem;
}
.workspace-catalog-heading,
.workspace-card-heading,
.workspace-create-row,
.workspace-switcher-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.workspace-catalog-heading h1,
.workspace-create-panel h2,
.workspace-catalog-shell h2 {
margin: 0.2rem 0 0.45rem;
}
.workspace-catalog-heading p,
.workspace-create-panel p,
.workspace-empty-state p {
color: var(--text-muted);
margin: 0;
}
.workspace-catalog-eyebrow {
color: var(--accent) !important;
font-size: var(--font-size-compact);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workspace-catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
gap: 1rem;
margin-top: 1rem;
}
.workspace-catalog-card,
.workspace-create-panel,
.workspace-empty-state {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--bg-raised);
}
.workspace-catalog-card {
color: inherit;
padding: 1.1rem;
display: grid;
gap: 0.65rem;
text-decoration: none;
}
.workspace-catalog-card:hover,
.workspace-catalog-card:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.workspace-catalog-card code,
.workspace-catalog-card small,
.workspace-repository-summary small {
color: var(--text-muted);
}
.workspace-card-heading > 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;
}
}
}
+2 -2
View File
@@ -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 = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
+6 -2
View File
@@ -7,8 +7,12 @@ export const ssr = false;
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({
deferredCatalog: settle((async () => {
// 認証/hostingの判断はWorkspace catalogを必要としない。
if (isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")) {
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
// dashboardはactor確認後に自身の一覧を取得する。
if (
url.pathname === "/" || url.pathname === "/dashboard" ||
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
) {
return { accessibleWorkspaces: [], workspaceCatalogError: null };
}
try {
+48 -7
View File
@@ -1,10 +1,51 @@
<script lang="ts">
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
import PageContent from "./PageContent.svelte";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
import { onMount } from 'svelte';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts';
import RegistrationLink from '#lib/workspace/auth/RegistrationLink.svelte';
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth();
onMount(() => {
const refresh = () => { void auth.refresh(); };
refresh();
window.addEventListener('focus', refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => {
auth.dispose();
window.removeEventListener('focus', refresh);
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
};
});
</script>
<DeferredPage {data} title="Workspaces">
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
</DeferredPage>
<svelte:head><title>Yoi · AIと進める開発</title></svelte:head>
<div class="auth-page public-home" data-sveltekit-preload-data="off">
<h1>Yoi</h1>
<p>AIとともに、開発を前へ。</p>
<p class="muted">Ticketで目的を共有し、AI Workerと実装を進める。Workspaceでコード・レビュー・作業の記録をまとめます。</p>
<section aria-label="Get started">
{#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'}
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
<button type="button" onclick={() => auth.refresh()}>Retry session</button>
<a href="/login">Open login</a>
{:else if auth.actor}
<p>Logged in as <strong>{auth.actor.display_name}</strong>.</p>
<a class="entry-primary" href={AUTHENTICATED_HOME}>Open dashboard</a>
{#key auth.actor.account_id}<AccountNavigation />{/key}
{:else}
<a class="entry-primary" href="/login">Log in</a>
<RegistrationLink />
<a href="/activate">Activate an account with a code</a>
{/if}
</section>
</div>
<style>
.public-home { padding-block: var(--space-6); }
.public-home > p { max-width: 40rem; }
</style>
-17
View File
@@ -1,17 +0,0 @@
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
import { loadWorkspaceCatalog } from "#lib/workspace/api/workspace-catalog.ts";
export const load = deferPage(async ({ fetch }: Parameters<PageLoad>[0]) => {
try {
return {
workspaces: await loadWorkspaceCatalog(fetch),
catalogError: null,
};
} catch (error) {
return {
workspaces: [],
catalogError: error instanceof Error ? error.message : String(error),
};
}
});
@@ -52,5 +52,5 @@
{/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<a href="/">Dashboard / Workspaces</a>
<a href="/dashboard">Dashboard / Workspaces</a>
</div>
@@ -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"),
@@ -0,0 +1,44 @@
<script lang="ts">
import { onMount } from 'svelte';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts';
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
import WorkspaceCatalog from './WorkspaceCatalog.svelte';
import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth();
onMount(() => {
const refresh = () => { void auth.refresh(); };
refresh();
window.addEventListener('focus', refresh);
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
return () => {
auth.dispose();
window.removeEventListener('focus', refresh);
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
};
});
</script>
<svelte:head><title>Dashboard · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off">
<header><a href="/">Yoi</a><h1>Dashboard</h1></header>
{#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'}
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
<button type="button" onclick={() => auth.refresh()}>Retry session</button>
<a href={loginUrl('/dashboard')}>Open login</a>
{:else if auth.actor}
{#key auth.actor.account_id}
<AccountNavigation />
<WorkspaceCatalog />
{/key}
{:else}
<p>Log in to select or create a Workspace.</p>
<a class="entry-primary" href={loginUrl('/dashboard')}>Log in</a>
{/if}
</div>
<style>header { display: grid; gap: var(--space-4); }</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import {
createOperationKey,
@@ -8,11 +9,10 @@
type CreateWorkspaceRequest,
type WorkspaceCatalogItem,
} from "#lib/workspace/api/workspace-catalog.ts";
import "#lib/workspace/styles/workspace-catalog.css";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
let { data }: {data: ResolvedPageData<RouteData>} = $props();
let disposed = false;
let request = 0;
let createOpen = $state(false);
let workspaces = $state<WorkspaceCatalogItem[]>([]);
let catalogError = $state<string | null>(null);
let refreshing = $state(false);
@@ -28,26 +28,32 @@
request: CreateWorkspaceRequest;
} | null>(null);
$effect(() => {
workspaces = data.workspaces;
catalogError = data.catalogError;
onMount(() => {
createOpen = window.location.hash === "#workspace-create-title";
const hashChanged = () => { if (window.location.hash === "#workspace-create-title") createOpen = true; };
window.addEventListener("hashchange", hashChanged);
void refreshCatalog();
return () => { disposed = true; ++request; window.removeEventListener("hashchange", hashChanged); };
});
async function refreshCatalog() {
const current = ++request;
refreshing = true;
workspaces = [];
catalogError = null;
try {
workspaces = await loadWorkspaceCatalog(fetch);
} catch (error) {
catalogError = error instanceof Error ? error.message : String(error);
const result = await loadWorkspaceCatalog(fetch);
if (!disposed && current === request) workspaces = result;
} catch {
if (!disposed && current === request) catalogError = "Workspaces could not be loaded. Check your session or retry.";
} finally {
refreshing = false;
if (!disposed && current === request) refreshing = false;
}
}
async function submitCreation(event: SubmitEvent) {
event.preventDefault();
if (creating) return;
if (creating || disposed) return;
const normalized = {
display_name: displayName.trim(),
repository: includeRepository ? {
@@ -65,9 +71,10 @@
creationError = null;
try {
const response = await createWorkspace(fetch, request);
if (disposed) return;
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
} catch (error) {
creationError = creationErrorMessage(error);
if (!disposed) creationError = creationErrorMessage(error);
} finally {
creating = false;
}
@@ -84,16 +91,10 @@
}
</script>
<svelte:head>
<title>Workspaces · Yoi</title>
</svelte:head>
<div class="workspace-catalog-shell">
<section class="workspace-catalog-heading">
<div>
<p class="workspace-catalog-eyebrow">Backend</p>
<h1>Workspaces</h1>
<p>Select an accessible team space or create one on this Backend.</p>
<p>Choose a Workspace to open its dashboard. Workspace settings are managed inside each Workspace.</p>
</div>
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
{refreshing ? "Refreshing…" : "Refresh"}
@@ -102,13 +103,17 @@
{#if catalogError}
<div class="workspace-catalog-alert" role="alert">
Refresh failed. Existing results were kept. {catalogError}
{catalogError}
</div>
{/if}
<section aria-labelledby="workspace-list-title">
<h2 id="workspace-list-title">Available Workspaces</h2>
{#if workspaces.length === 0}
<h2 id="workspace-list-title">Choose Workspace</h2>
{#if refreshing}
<p role="status">Loading Workspaces…</p>
{:else if catalogError}
<a href="/account">Check Account</a>
{:else if workspaces.length === 0}
<div class="workspace-empty-state">
<strong>No accessible Workspaces</strong>
<p>Create the first Workspace if you have Backend permission.</p>
@@ -126,7 +131,6 @@
{workspace.state}
</span>
</span>
<code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]}
<span class="workspace-repository-summary">
{workspace.repositories[0].repository_key}
@@ -147,10 +151,10 @@
{/if}
</section>
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
<details class="workspace-create-panel" bind:open={createOpen}>
<summary id="workspace-create-title">Create Workspace</summary>
<div class="workspace-create-content">
<div>
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<p>You can add repositories later in Workspace Settings.</p>
</div>
<form onsubmit={submitCreation}>
@@ -190,5 +194,39 @@
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
</button>
</form>
</section>
</div>
</details>
</div>
<style>
.workspace-catalog-shell { display: grid; gap: var(--space-5); min-width: 0; }
.workspace-catalog-heading, .workspace-card-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); }
h2 { margin: 0 0 var(--space-3); font-size: var(--font-size-body); }
p { margin: 0; color: var(--text-muted); }
.workspace-catalog-grid { display: grid; }
.workspace-catalog-card { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; text-decoration: none; color: var(--text-strong); }
.workspace-catalog-card:hover { background: var(--interactive-hover); }
.workspace-card-heading strong { min-width: 0; }
.workspace-card-heading > span { flex: 0 0 auto; font-size: var(--font-size-compact); color: var(--text-muted); }
.workspace-card-heading > .workspace-state-active { color: var(--success); }
small { color: var(--text-muted); font-size: var(--font-size-compact); }
.workspace-repository-summary { display: grid; gap: var(--space-1); }
.workspace-empty-state { padding-block: var(--space-4); }
.workspace-create-panel, .workspace-create-content, form { min-width: 0; }
summary { cursor: pointer; font-weight: 700; }
.workspace-create-content, form, .workspace-repository-fields { display: grid; gap: var(--space-4); }
.workspace-create-content { padding-block: var(--space-4); }
label { display: grid; gap: var(--space-2); min-width: 0; }
input { width: 100%; min-width: 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
.workspace-repository-toggle { display: flex; align-items: center; gap: var(--space-2); }
input[type=checkbox] { width: auto; }
.workspace-create-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: var(--space-4); }
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
.workspace-primary-action { background: var(--text-strong); color: var(--bg); }
button:hover:not(:disabled), summary:hover { background: var(--interactive-hover); color: var(--text-strong); }
:is(a, button, summary):active { background: var(--interactive-selected); color: var(--text-strong); }
button:disabled { opacity: .55; cursor: not-allowed; }
:is(a, button, input, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.workspace-catalog-alert { color: var(--danger); }
@media (max-width: 760px) { .workspace-catalog-heading { align-items: start; flex-direction: column; } }
</style>
@@ -0,0 +1,240 @@
// @vitest-environment happy-dom
// UI→本番clientを接続しHTTPだけ代替する。実Serverの認証・認可・SQLiteは共有E2Eが所有する。
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import Home from "./+page.svelte";
import Dashboard from "./dashboard/+page.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
import {
createdWorkspace,
creationResponse,
} from "#lib/workspace/api/workspace-catalog.test-fixtures.ts";
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
import { goto } from "$app/navigation";
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function boundary() {
const state = {
signedIn: false,
whoamiStatus: 200,
policy: true as boolean | "error" | "loading",
admin: false,
catalogStatus: 200,
count: 0,
createStatus: 200,
sessionGate: null as ReturnType<typeof deferred<Response>> | null,
catalogGate: null as ReturnType<typeof deferred<Response>> | null,
};
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
if (state.sessionGate) return state.sessionGate.promise;
return state.whoamiStatus === 200
? Response.json({
actor: state.signedIn
? { ...authUser, auth_method: "browser_session" }
: null,
})
: new Response(null, { status: state.whoamiStatus });
}
if (path === "/api/auth/registration-policy") {
if (state.policy === "loading") return new Promise<Response>(() => {});
return state.policy === "error"
? new Response(null, { status: 503 })
: Response.json({ self_registration_enabled: state.policy });
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: state.admin });
}
if (init?.method === "POST") {
return state.createStatus === 200
? Response.json(creationResponse())
: new Response(null, { status: state.createStatus });
}
if (path.startsWith("/api/workspaces?")) {
if (state.catalogGate) return state.catalogGate.promise;
return state.catalogStatus === 200
? Response.json(
Array.from(
{ length: state.count },
(_, i) => ({
...createdWorkspace,
workspace_id: `space-${i}`,
display_name: `Team ${i}`,
}),
),
)
: new Response(null, { status: state.catalogStatus });
}
if (path.includes("/repositories")) {
return Response.json({
workspace_id: "fixture",
items: [],
source: "workspace-control-plane",
diagnostics: [],
});
}
throw new Error(`Unexpected request ${path}`);
},
);
vi.stubGlobal("fetch", fetcher);
return {
state,
fetcher,
workspaceCalls: () =>
fetcher.mock.calls.filter(([path]) =>
String(path).startsWith("/api/workspaces")
),
};
}
for (const policy of [true, false, "error", "loading"] as const) {
test(`public home keeps login and exposes registration only for confirmed allowed policy: ${policy}`, async () => {
const { state, workspaceCalls } = boundary();
state.policy = policy;
state.catalogStatus = 401;
render(Home);
expect(
(await screen.findByRole("link", { name: "Log in" })).getAttribute(
"href",
),
).toBe("/login");
if (policy === true) {
expect(
(await screen.findByRole("link", { name: "Create a new account" }))
.getAttribute("href"),
).toBe("/register?returnTo=%2Fdashboard");
} else {
await screen.findByText(
policy === false
? /registration is disabled/
: policy === "error"
? /availability is unknown/
: /Checking registration/,
);
expect(screen.queryByRole("link", { name: "Create a new account" }))
.toBeNull();
}
expect(workspaceCalls()).toEqual([]);
expect(screen.queryByText(/No accessible Workspaces/)).toBeNull();
});
}
test("whoami loading and failure never expose anonymous actions or Workspace content; retry confirms actor", async () => {
const { state, workspaceCalls } = boundary();
state.sessionGate = deferred<Response>();
render(Home);
await screen.findByText("Loading session…");
expect(screen.queryByRole("link")).toBeNull();
state.sessionGate.resolve(new Response(null, { status: 503 }));
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Log in" })).toBeNull();
expect(screen.queryByRole("link", { name: "Create a new account" }))
.toBeNull();
state.sessionGate = null;
state.signedIn = true;
await fireEvent.click(screen.getByRole("button", { name: "Retry session" }));
expect(
(await screen.findByRole("link", { name: "Open dashboard" })).getAttribute(
"href",
),
).toBe("/dashboard");
expect(workspaceCalls()).toEqual([]);
expect(goto).not.toHaveBeenCalled();
});
for (const admin of [false, true]) {
test(`confirmed actor account/hosting navigation reflects live admin=${admin}`, async () => {
const { state } = boundary();
state.signedIn = true;
state.admin = admin;
render(Home);
await screen.findByRole("link", { name: "Account" });
if (admin) await screen.findByRole("link", { name: "Hosting settings" });
else {expect(screen.queryByRole("link", { name: "Hosting settings" }))
.toBeNull();}
state.signedIn = false;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
await screen.findByRole("link", { name: "Log in" });
expect(screen.queryByRole("link", { name: "Hosting settings" })).toBeNull();
});
}
for (const count of [0, 1, 3]) {
test(`dashboard ${count} Workspaces requires explicit selection and offers creation`, async () => {
const { state } = boundary();
state.signedIn = true;
state.count = count;
render(Dashboard);
if (count === 0) await screen.findByText("No accessible Workspaces");
else {
await screen.findByRole("link", { name: /Team 0/ });
expect(screen.getAllByRole("link", { name: /Team \d/ })).toHaveLength(
count,
);
expect(screen.getByRole("link", { name: /Team 0/ }).getAttribute("href"))
.toBe("/w/space-0");
}
expect(goto).not.toHaveBeenCalled();
await fireEvent.click(
screen.getByText("Create Workspace", { selector: "summary" }),
);
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
target: { value: "New Workspace" },
});
await fireEvent.submit(
screen.getByRole("button", { name: "Create Workspace" }).closest("form")!,
);
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
});
}
for (const status of [401, 503]) {
test(`dashboard catalog failure ${status} is not empty; account and creation recovery remain`, async () => {
const { state } = boundary();
state.signedIn = true;
state.catalogStatus = status;
render(Dashboard);
await screen.findByRole("alert");
expect(screen.queryByText("No accessible Workspaces")).toBeNull();
expect(screen.getByRole("link", { name: "Account" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy();
});
}
test("dashboard removes private content on session change and ignores old catalog completion", async () => {
const { state } = boundary();
state.signedIn = true;
state.catalogGate = deferred<Response>();
render(Dashboard);
await screen.findByText("Loading Workspaces…");
state.signedIn = false;
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
await screen.findByText("Log in to select or create a Workspace.");
state.catalogGate.resolve(Response.json([createdWorkspace]));
expect(screen.queryByText("New Workspace")).toBeNull();
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
});
test("dashboard does not contact catalog for anonymous or unknown session", async () => {
const { state, workspaceCalls } = boundary();
const view = render(Dashboard);
await screen.findByRole("link", { name: "Log in" });
expect(workspaceCalls()).toEqual([]);
view.unmount();
state.whoamiStatus = 503;
render(Dashboard);
await screen.findByRole("alert");
expect(workspaceCalls()).toEqual([]);
});
@@ -7,7 +7,7 @@ import { logout } from "#lib/workspace/auth/api.ts";
import { settle } from '#lib/workspace/navigation/deferred.ts';
import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/w/example") } }));
const storageKey = "yoi.sidebar.mode.v1";
const legacyKey = "yoi.sidebar.folded.v1";
const props = {
@@ -8,7 +8,7 @@ import {
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { goto } from "$app/navigation";
import Catalog from "./PageContent.svelte";
import Catalog from "./dashboard/WorkspaceCatalog.svelte";
import {
createdWorkspace,
creationResponse,
@@ -20,15 +20,25 @@ afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function mount() {
return render(Catalog, {
props: {
data: {
workspaces: [{ ...createdWorkspace, repositories: [] }],
catalogError: null,
async function mount() {
const mutationFetch = globalThis.fetch;
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
if (init?.method === "POST") return mutationFetch(url, init);
return Response.json(
url.startsWith("/api/workspaces?") ? [createdWorkspace] : {
workspace_id: "home-empty",
items: [],
source: "workspace-control-plane",
diagnostics: [],
},
},
);
});
const view = render(Catalog);
await screen.findByRole("link", { name: /New Workspace.*No repositories/ });
await fireEvent.click(
screen.getByText("Create Workspace", { selector: "summary" }),
);
return view;
}
function submit() {
return fireEvent.submit(
@@ -42,7 +52,7 @@ test("blank repository form creates and opens a Workspace using a null repositor
Response.json(creationResponse()),
);
vi.stubGlobal("fetch", fetcher);
mount();
await mount();
expect(
screen.getByRole("link", { name: /New Workspace.*No repositories/ })
.getAttribute("href"),
@@ -64,7 +74,7 @@ test("opting into a repository requires an explicit key and source and submits n
Response.json(creationResponse()),
);
vi.stubGlobal("fetch", fetcher);
mount();
await mount();
await fireEvent.click(
screen.getByRole("checkbox", {
name: "Add an initial repository (optional)",
@@ -98,7 +108,7 @@ test("unchanged empty creation retries reuse the operation key and accept delete
)
.mockResolvedValueOnce(Response.json(creationResponse(null, true)));
vi.stubGlobal("fetch", fetcher);
mount();
await mount();
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
target: { value: "New Workspace" },
});
@@ -116,7 +126,7 @@ test("opting out ignores retained repository inputs and changes the creation ope
Response.json({ message: "Temporary failure" }, { status: 503 })
);
vi.stubGlobal("fetch", fetcher);
mount();
await mount();
const toggle = screen.getByRole("checkbox", {
name: "Add an initial repository (optional)",
});
@@ -303,7 +303,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
const link = page.locator('a[href^="/login"]').first();
await link.click();
await login(page);
await at(page, "/");
await at(page, "/dashboard");
await page.goto(`${origin}/account`);
await page.getByRole("button", {
name: "Add passkey to alice",
@@ -490,7 +490,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
await page.getByRole("button", { name: "Retry session", exact: true })
.click();
await login(page);
await at(page, "/");
await at(page, "/dashboard");
}),
);
for (const route of ["login", "register"]) {
@@ -500,9 +500,9 @@ Deno.test("standalone auth separates login, registration, own account and explic
isolated(browser!, async ({ page, go }) => {
await go(`/${route}`);
await (route === "login" ? login(page) : register(page));
await at(page, "/");
await at(page, "/dashboard");
await page.reload();
assertEquals(new URL(page.url()).pathname, "/");
assertEquals(new URL(page.url()).pathname, "/dashboard");
}),
);
for (
@@ -523,7 +523,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
isolated(browser!, async ({ page, go }) => {
await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`);
await (route === "login" ? login(page) : register(page));
await at(page, "/");
await at(page, "/dashboard");
}),
);
}
@@ -619,7 +619,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
} else await at(page, "/");
} else await at(page, "/dashboard");
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
1,
@@ -0,0 +1,350 @@
/// <reference lib="dom" />
// 本番build/shell/router/clientと合成HTTP境界の検証。実Server/SQLite/SoftPasskey E2Eとは別。
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium, type Page } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
const root = resolve(import.meta.dirname!, "../../../..");
const origin = "http://127.0.0.1:15411";
const ready = (page: Page, name: string) =>
page.getByRole("link", { name, exact: true }).waitFor();
Deno.test("public entry isolates Workspace authority and authenticated dashboard supports explicit selection", async (t) => {
const processes = await startOwnedProcesses(
[
{
id: "workspace-fixture",
command: "deno",
cwd: root,
args: [
"run",
"--allow-read",
"--allow-net",
"tools/web-ux/browser-tests/workspace_home_fixture_server.ts",
"15412",
"web/workspace/build",
],
readyUrl: "http://127.0.0.1:15412/health",
},
{
id: "entry-fixture",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
"15411",
"web/workspace/build",
"http://127.0.0.1:15412",
],
readyUrl: `${origin}/health`,
},
],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/entry-browser/processes`,
[],
);
try {
const browser = await chromium.launch();
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const step = async (
name: string,
body: (page: Page) => Promise<void>,
) =>
await t.step(`${colorScheme}/${width}: ${name}`, async () => {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await body(page);
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
assertEquals(errors, []);
} finally {
await context.close();
}
});
for (
const mode of [
"anonymous",
"policy-disabled",
"policy-error",
"policy-loading",
]
) {
await step(
`public/login policy ${mode}, no catalog calls even if unavailable`,
async (page) => {
let workspaceCalls = 0;
await page.route("**/api/workspaces*", (route) => {
workspaceCalls++;
return route.fulfill({ status: 503 });
});
await page.goto(`${origin}/?fixture=${mode}`);
await page.getByRole("heading", { name: "Yoi", exact: true })
.waitFor();
await ready(page, "Log in");
if (mode === "anonymous") {
await ready(page, "Create a new account");
assertEquals(
await page.getByRole("link", {
name: "Create a new account",
}).getAttribute("href"),
"/register?returnTo=%2Fdashboard",
);
} else {
await page.getByText(
mode === "policy-disabled"
? /registration is disabled/
: mode === "policy-error"
? /availability is unknown/
: /Checking registration/,
).waitFor();
assertEquals(
await page.getByRole("link", {
name: "Create a new account",
}).count(),
0,
);
}
await page.getByRole("link", { name: "Log in", exact: true })
.click();
await page.getByRole("button", { name: "Log in with passkey" })
.waitFor();
if (mode === "policy-disabled") {
await page.getByText(/registration is disabled/).waitFor();
assertEquals(
await page.getByRole("link", {
name: "Create a new account",
}).count(),
0,
);
}
assertEquals(workspaceCalls, 0);
assertEquals(
await page.locator(".workspace-catalog-alert").count(),
0,
);
},
);
}
for (const mode of ["session-error", "session-loading"]) {
await step(
`public ${mode} is not anonymous or authenticated`,
async (page) => {
await page.goto(`${origin}/?fixture=${mode}`);
await page.getByText(
mode === "session-error"
? /Login status is unknown/
: "Loading session…",
).waitFor();
assertEquals(
await page.getByRole("link", { name: "Log in", exact: true })
.count(),
0,
);
assertEquals(
await page.getByRole("link", { name: "Create a new account" })
.count(),
0,
);
assertEquals(
await page.getByRole("link", { name: "Open dashboard" })
.count(),
0,
);
},
);
}
await step(
"session retry and focus/session change remove stale actor/admin links",
async (page) => {
await page.goto(`${origin}/?fixture=session-retry`);
await page.getByRole("button", { name: "Retry session" }).click();
await ready(page, "Log in");
await page.goto(`${origin}/?fixture=admin`);
await ready(page, "Hosting settings");
await ready(page, "Open dashboard");
await page.request.post(`${origin}/api/auth/logout`);
await page.evaluate(() =>
window.dispatchEvent(new Event("yoi:browser-session-changed"))
);
await ready(page, "Log in");
assertEquals(
await page.getByRole("link", { name: "Hosting settings" })
.count(),
0,
);
assertEquals(
await page.getByRole("link", { name: "Open dashboard" })
.count(),
0,
);
},
);
for (
const mode of [
"account",
"workspace-one",
"workspace-many",
"admin",
]
) {
await step(
`actor ${mode}: dashboard and explicit Workspace navigation`,
async (page) => {
await page.goto(`${origin}/?fixture=${mode}`);
await ready(page, "Open dashboard");
await ready(page, "Account");
if (mode === "admin") await ready(page, "Hosting settings");
else {assertEquals(
await page.getByRole("link", { name: "Hosting settings" })
.count(),
0,
);}
await page.getByRole("link", { name: "Open dashboard" })
.click();
await page.getByRole("heading", {
name: "Dashboard",
exact: true,
}).waitFor();
if (mode === "workspace-one" || mode === "workspace-many") {
const links = page.locator(".workspace-catalog-card");
await links.first().waitFor();
assertEquals(
await links.count(),
mode === "workspace-one" ? 1 : 3,
);
assertEquals(new URL(page.url()).pathname, "/dashboard");
await links.first().click();
await page.waitForURL("**/w/home-empty");
await page.locator(".workspace-home").waitFor();
// canonical direct link remains independently reopenable, including another Workspace.
await page.goto(`${origin}/w/home-member`);
await page.locator(".workspace-home").waitFor();
assertEquals(new URL(page.url()).pathname, "/w/home-member");
} else {
await page.getByText("No accessible Workspaces", {
exact: true,
}).waitFor();
await page.locator("summary#workspace-create-title").click();
await page.getByLabel("Workspace display name").fill(
"New Workspace",
);
const submitted = page.waitForRequest((request) =>
new URL(request.url()).pathname === "/api/workspaces" &&
request.method() === "POST"
);
await page.getByRole("button", {
name: "Create Workspace",
exact: true,
}).click();
const keys = Object.keys((await submitted).postDataJSON())
.sort();
assertEquals(keys, [
"display_name",
"operation_key",
"repository",
]);
await page.waitForURL("**/w/home-empty");
await page.locator(".workspace-home").waitFor();
}
},
);
}
for (
const mode of [
"workspace-unauthorized",
"workspace-error",
"workspace-loading",
]
) {
await step(
`catalog ${mode} does not erase account or imply empty`,
async (page) => {
await page.goto(`${origin}/dashboard?fixture=${mode}`);
await ready(page, "Account");
await page.getByText(
mode === "workspace-loading"
? "Loading Workspaces…"
: /Workspaces could not be loaded/,
).waitFor();
assertEquals(
await page.getByText("No accessible Workspaces", {
exact: true,
}).count(),
0,
);
assertEquals(new URL(page.url()).pathname, "/dashboard");
await page.getByRole("link", { name: "Yoi", exact: true })
.click();
await ready(page, "Open dashboard");
assertEquals(await page.getByRole("alert").count(), 0);
},
);
}
for (const mode of ["creation-denied", "creation-error"]) {
await step(
`explicit creation ${mode} retains input and exact retry operation`,
async (page) => {
await page.goto(
`${origin}/dashboard?fixture=${mode}#workspace-create-title`,
);
await page.getByLabel("Workspace display name").fill(
"New Workspace",
);
const bodies: unknown[] = [];
page.on("request", (request) => {
if (request.method() === "POST") {
bodies.push(request.postDataJSON());
}
});
await page.getByRole("button", {
name: "Create Workspace",
exact: true,
}).click();
await page.getByRole("alert").waitFor();
await page.getByRole("button", {
name: "Retry creation",
exact: true,
}).click();
await page.getByRole("button", {
name: "Retry creation",
exact: true,
}).waitFor();
assertEquals(bodies.length, 2);
assertEquals(bodies[0], bodies[1]);
assertEquals(
await page.getByLabel("Workspace display name").inputValue(),
"New Workspace",
);
assertEquals(new URL(page.url()).pathname, "/dashboard");
},
);
}
}
}
} finally {
await browser.close();
}
} finally {
assertEquals(await stopOwnedProcesses(processes), []);
}
});
@@ -3,7 +3,12 @@
import { extname, join, resolve } from "@std/path";
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
const [port, build] = Deno.args;
import {
createdWorkspace,
creationResponse,
} from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
const [port, build, workspaceFixture] = Deno.args;
if (!port || !build) throw new Error("Expected port and production build root");
const root = resolve(build);
const user = {
@@ -51,6 +56,8 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
sessions.set(id, {
mode: selected ?? "anonymous",
signedIn: selected === "account" ||
selected?.startsWith("workspace-") === true || selected === "admin" ||
selected?.startsWith("creation-") === true ||
selected?.startsWith("addition-") === true,
whoamiCalls: 0,
selfRegistrationEnabled: selected !== "policy-disabled",
@@ -98,17 +105,44 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
});
}
if (path === "/api/auth/registration-policy") {
if (mode === "policy-loading") return await new Promise<Response>(() => {});
if (mode === "policy-error") return new Response(null, { status: 503 });
return Response.json({
self_registration_enabled: state.selfRegistrationEnabled,
});
}
if (path === "/api/hosting/access") return Response.json({ is_admin: false });
if (path === "/api/hosting/access") {
return Response.json({ is_admin: mode === "admin" });
}
if (path === "/api/workspaces") {
// fallbackは現行Workspace選択まで。公開トップ/T760の挙動を新設しない。
return state.signedIn
? Response.json([])
: new Response(null, { status: 401 });
if (!state.signedIn || mode === "workspace-unauthorized") {
return new Response(null, { status: 401 });
}
if (mode === "workspace-error") return new Response(null, { status: 503 });
if (mode === "workspace-loading") {
return await new Promise<Response>(() => {});
}
if (request.method === "POST") {
if (mode === "creation-denied") {
return new Response(null, { status: 403 });
}
if (mode === "creation-error") return new Response(null, { status: 503 });
return Response.json(creationResponse());
}
const ids = mode === "workspace-one"
? ["home-empty"]
: mode === "workspace-many"
? ["home-empty", "home-member", "home-long"]
: [];
return Response.json(ids.map((workspace_id, i) => ({
...createdWorkspace,
workspace_id,
display_name: i === 0
? "New Workspace"
: i === 1
? "Shared Workspace"
: "Workspace with a long name — international documentation and distributed development",
})));
}
if (path === "/api/auth/logout") {
state.signedIn = false;
@@ -153,6 +187,30 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.signedIn = true;
return Response.json({ user });
}
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
if (workspaceFixture && path.startsWith("/api/w/")) {
const target = new URL(path + url.search, workspaceFixture);
if (request.headers.get("upgrade") === "websocket") {
const { socket, response } = Deno.upgradeWebSocket(request);
target.protocol = "ws:";
const peer = new WebSocket(target);
const pending: string[] = [];
socket.onmessage = (event) =>
peer.readyState === WebSocket.OPEN
? peer.send(event.data)
: pending.push(String(event.data));
peer.onopen = () => {
for (const frame of pending) peer.send(frame);
};
peer.onmessage = (event) => {
if (socket.readyState === WebSocket.OPEN) socket.send(event.data);
};
socket.onclose = () => peer.close();
peer.onclose = () => socket.close();
return response;
}
return await fetch(new Request(target, request));
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
let file: string;
try {
@@ -86,7 +86,7 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
}
});
await page.goto("http://127.0.0.1:15379/?fixture=admin-on");
await page.getByRole("heading", { name: "Workspaces", exact: true })
await page.getByRole("heading", { name: "Yoi", exact: true })
.waitFor();
if (
await page.getByRole("button", {
@@ -99,16 +99,11 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
exact: true,
}).click();
}
const nav = page.getByRole("navigation", { name: "Global pages" });
const nav = page.getByRole("navigation", { name: "Your account" });
await nav.getByRole("link", { name: "Hosting settings", exact: true })
.waitFor();
await nav.getByRole("link", { name: "Account", exact: true }).focus();
await page.keyboard.press("Tab");
assertEquals(
await page.locator(":focus").innerText(),
"Device Login",
);
await page.keyboard.press("Tab");
assertEquals(
await page.locator(":focus").innerText(),
"Hosting settings",
+2
View File
@@ -4,6 +4,8 @@ import { load, ssr } from "../src/routes/+layout.ts";
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
for (
const path of [
"/",
"/dashboard",
"/activate",
"/login",
"/register",
@@ -11,6 +11,8 @@ import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fi
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/api/auth/whoami") return Response.json({ actor: { user_id: "fixture-user", account_id: "fixture-owner", handle: "alice", display_name: "Alice", auth_method: "browser_session" } });
if (url.pathname === "/api/hosting/access") return Response.json({ is_admin: false });
if (url.pathname === "/api/workspaces" && request.method === "POST") {
return Response.json({
message:
@@ -61,7 +61,7 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin
creationBodies.push(route.request().postDataJSON());
await route.fulfill({ json: creationResponse() });
});
await page.goto(scenario.baseUrl!);
await page.goto(scenario.baseUrl! + "/dashboard#workspace-create-title");
const name = page.getByLabel("Workspace display name");
await name.fill("New Workspace");
const toggle = page.getByRole("checkbox", {
@@ -119,7 +119,7 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin
await page.locator("[data-feed-ready=home-active]").waitFor();
assertEquals(await page.locator("main [role=alert]").count(), 0);
// Reopening is based on Workspace identity, not a repository record.
await page.goto(scenario.baseUrl!);
await page.goto(scenario.baseUrl! + "/dashboard");
await page.locator('a.workspace-catalog-card[href="/w/home-empty"]')
.click();
await page.waitForURL("**/w/home-empty");
@@ -35,7 +35,7 @@
{
"id": "catalog",
"label": "Create Workspace without a repository",
"path": "/",
"path": "/dashboard#workspace-create-title",
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
"dataState": "Opt-in Git fields followed by intentional HTTP400 creation validation failure; no actual resources are created.",
"ready": {
@@ -35,7 +35,7 @@
{
"id": "catalog",
"label": "Create Workspace without a repository",
"path": "/",
"path": "/dashboard#workspace-create-title",
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
"dataState": "Accessible Workspaces with no repositories and blank creation form.",
"ready": {