feat: add public entry and authenticated workspace dashboard
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+66
-28
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user