feat: add public entry and authenticated workspace dashboard
This commit is contained in:
@@ -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