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
@@ -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": {