fix: separate workspace settings display and editing
This commit is contained in:
@@ -198,6 +198,16 @@ Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included
|
|||||||
also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error
|
also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error
|
||||||
feeds, explicit refresh, and absence of Host requests.
|
feeds, explicit refresh, and absence of Host requests.
|
||||||
|
|
||||||
|
## Workspace Identity settings fixture
|
||||||
|
|
||||||
|
`scenarios/workspace-settings.json` uses the same owned fixture server with synthetic metadata,
|
||||||
|
public identity, and deletion-preflight responses. It covers owner, member, long name, pending
|
||||||
|
provisioning, and intentional signing-identity HTTP503 states at 1440px, 768px, and 390px.
|
||||||
|
Build `web/workspace` before capture. No real keys, accounts, or deletion requests are used.
|
||||||
|
`browser-tests/workspace_settings_shell_test.ts` also checks 320px and both themes, Edit/Save/Cancel,
|
||||||
|
keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion
|
||||||
|
confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`.
|
||||||
|
|
||||||
## Adding a scenario
|
## Adding a scenario
|
||||||
|
|
||||||
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||||
import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
|
import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||||
|
|
||||||
|
import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts";
|
||||||
|
const settingsFixture = settingsFixtureHandler();
|
||||||
|
|
||||||
const port = Number(Deno.args[0]);
|
const port = Number(Deno.args[0]);
|
||||||
const buildRoot = Deno.args[1];
|
const buildRoot = Deno.args[1];
|
||||||
if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server <port> <build-root>");
|
if (!Number.isInteger(port) || !buildRoot) {
|
||||||
|
throw new Error("usage: server <port> <build-root>");
|
||||||
|
}
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
"home-owner": "Workspace Home Review",
|
"home-owner": "Workspace Home Review",
|
||||||
"home-member": "Shared Workspace",
|
"home-member": "Shared Workspace",
|
||||||
@@ -25,6 +30,8 @@ const mime: Record<string, string> = {
|
|||||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/health") return new Response("ok");
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
|
const settingsResponse = await settingsFixture(request);
|
||||||
|
if (settingsResponse) return settingsResponse;
|
||||||
if (url.pathname === "/api/workspaces") {
|
if (url.pathname === "/api/workspaces") {
|
||||||
return json(
|
return json(
|
||||||
Object.entries(names).map(([workspace_id, display_name]) => ({
|
Object.entries(names).map(([workspace_id, display_name]) => ({
|
||||||
@@ -69,13 +76,26 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
extension_points: {
|
extension_points: {
|
||||||
store: "fixture",
|
store: "fixture",
|
||||||
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||||
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
host_worker_bridge: {
|
||||||
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
status: "ready",
|
||||||
|
note: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
companion_console: {
|
||||||
|
status: "ready",
|
||||||
|
note: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (path === "/repositories") {
|
if (path === "/repositories") {
|
||||||
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
|
return json({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
items: [],
|
||||||
|
source: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (path === "/working-directories") {
|
if (path === "/working-directories") {
|
||||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||||
@@ -110,7 +130,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
record_source: "fixture",
|
record_source: "fixture",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") {
|
if (
|
||||||
|
path === "/protocol/ws" && request.headers.get("upgrade") === "websocket"
|
||||||
|
) {
|
||||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
const frame = JSON.parse(String(event.data));
|
const frame = JSON.parse(String(event.data));
|
||||||
@@ -135,12 +157,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
return json({ error: "Unexpected fixture API request" }, 404);
|
return json({ error: "Unexpected fixture API request" }, 404);
|
||||||
}
|
}
|
||||||
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||||
if (relative.startsWith("..")) return new Response("not found", { status: 404 });
|
if (relative.startsWith("..")) {
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
let file = join(buildRoot, relative);
|
let file = join(buildRoot, relative);
|
||||||
try {
|
try {
|
||||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||||
return new Response(await Deno.readFile(file), {
|
return new Response(await Deno.readFile(file), {
|
||||||
headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" },
|
headers: {
|
||||||
|
"content-type": mime[extname(file)] ?? "application/octet-stream",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
|
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { dirname, fromFileUrl, join } from "@std/path";
|
||||||
|
import { chromium, type Page } from "playwright";
|
||||||
|
import { metadataFixture } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts";
|
||||||
|
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
const evidence = join(root, "target/web-ux/settings-interactions");
|
||||||
|
async function ready(page: Page, owner = true) {
|
||||||
|
await page.locator("[data-name-ready=true]").waitFor();
|
||||||
|
if (owner) await page.locator("[data-identity-ready=true]").waitFor();
|
||||||
|
}
|
||||||
|
async function noOverflow(page: Page) {
|
||||||
|
assertEquals(
|
||||||
|
await page.locator("main").evaluate((main) => ({
|
||||||
|
page: document.documentElement.scrollWidth > innerWidth,
|
||||||
|
main: main.scrollWidth > main.clientWidth,
|
||||||
|
})),
|
||||||
|
{ page: false, main: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Deno.test("production Workspace Settings separates read/edit, discloses details, and preserves keyboard and destructive safety", async () => {
|
||||||
|
const build = await new Deno.Command(Deno.execPath(), {
|
||||||
|
args: ["task", "build"],
|
||||||
|
cwd: join(root, "web/workspace"),
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).output();
|
||||||
|
assert(build.success, new TextDecoder().decode(build.stderr));
|
||||||
|
await Deno.mkdir(evidence, { recursive: true });
|
||||||
|
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||||
|
const port = (listener.addr as Deno.NetAddr).port;
|
||||||
|
listener.close();
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const server = new Deno.Command(Deno.execPath(), {
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
|
||||||
|
String(port),
|
||||||
|
join(root, "web/workspace/build"),
|
||||||
|
],
|
||||||
|
stdout: "null",
|
||||||
|
stderr: "null",
|
||||||
|
}).spawn();
|
||||||
|
try {
|
||||||
|
let started = false;
|
||||||
|
for (let n = 0; n < 100; n++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/health`);
|
||||||
|
await response.body?.cancel();
|
||||||
|
if (response.ok) {
|
||||||
|
started = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { /* starting */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
assert(started);
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390, 320]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
const mutations: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
page.on("requestfailed", (request) => errors.push(request.url()));
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.method() !== "GET") mutations.push(request.url());
|
||||||
|
});
|
||||||
|
for (
|
||||||
|
const workspace of ["home-owner", "home-member", "home-long"]
|
||||||
|
) {
|
||||||
|
const owner = workspace !== "home-member";
|
||||||
|
await page.goto(`${url}/w/${workspace}/settings`);
|
||||||
|
await ready(page, owner);
|
||||||
|
assertEquals(await page.locator("main h1").allTextContents(), [
|
||||||
|
"Workspace Identity",
|
||||||
|
]);
|
||||||
|
assertEquals(
|
||||||
|
await page.locator("main input:visible, main textarea:visible")
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assertEquals(await page.locator("main details[open]").count(), 0);
|
||||||
|
assertEquals(await page.locator("main .badge").count(), 0);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("button", { name: "Copy bundle" }).count(),
|
||||||
|
owner ? 1 : 0,
|
||||||
|
);
|
||||||
|
const edit = page.getByRole("button", { name: "Edit name" });
|
||||||
|
assert((await edit.boundingBox())!.y < 900);
|
||||||
|
await noOverflow(page);
|
||||||
|
const saved = await page.locator(".saved-name").innerText();
|
||||||
|
await edit.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const input = page.getByRole("textbox", {
|
||||||
|
name: "New display name",
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
await input.evaluate((node) => node === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(await input.inputValue(), saved);
|
||||||
|
await input.fill("Discard draft");
|
||||||
|
if (workspace === "home-long") {
|
||||||
|
await page.screenshot({
|
||||||
|
path: join(evidence, `edit-${colorScheme}-${width}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.getByRole("button", { name: "Cancel", exact: true })
|
||||||
|
.click();
|
||||||
|
assertEquals(
|
||||||
|
await edit.evaluate((node) => node === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await page.locator(".saved-name").innerText(),
|
||||||
|
saved,
|
||||||
|
);
|
||||||
|
const technical = page.getByText("Technical details", {
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await technical.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
assertEquals(
|
||||||
|
await page.getByText("Source", { exact: true }).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
assertEquals(
|
||||||
|
await page.getByText("Source", { exact: true }).isVisible(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (owner) {
|
||||||
|
const bundle = page.getByText("Public bundle and key details", {
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await bundle.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("textbox", {
|
||||||
|
name: "Public identity bundle",
|
||||||
|
}).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("textbox", {
|
||||||
|
name: "Public identity bundle",
|
||||||
|
}).getAttribute("readonly"),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
await noOverflow(page);
|
||||||
|
if (workspace === "home-owner") {
|
||||||
|
await page.screenshot({
|
||||||
|
path: join(evidence, `bundle-${colorScheme}-${width}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await bundle.click();
|
||||||
|
const danger = page.locator(".deletion > summary");
|
||||||
|
await danger.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Review deletion impact",
|
||||||
|
}).click();
|
||||||
|
const confirmation = page.getByRole("textbox", {
|
||||||
|
name: /^Type .* to confirm$/,
|
||||||
|
});
|
||||||
|
await confirmation.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await confirmation.evaluate((node) => node === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const deletion = page.getByRole("button", {
|
||||||
|
name: "Delete Workspace",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assertEquals(await deletion.isDisabled(), true);
|
||||||
|
await confirmation.fill("wrong");
|
||||||
|
assertEquals(await deletion.isDisabled(), true);
|
||||||
|
await confirmation.fill(saved);
|
||||||
|
assertEquals(await deletion.isDisabled(), false);
|
||||||
|
await noOverflow(page);
|
||||||
|
if (workspace === "home-owner") {
|
||||||
|
await page.screenshot({
|
||||||
|
path: join(evidence, `delete-${colorScheme}-${width}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.getByRole("button", { name: "Cancel", exact: true })
|
||||||
|
.click();
|
||||||
|
assertEquals(
|
||||||
|
await danger.evaluate((node) => node === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(await page.locator(".deletion[open]").count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(mutations, []);
|
||||||
|
assertEquals(errors, []);
|
||||||
|
await page.getByRole("button", { name: "Edit name" }).click();
|
||||||
|
const renamed = `Confirmed name ${width} ${colorScheme}`;
|
||||||
|
await page.getByRole("textbox", { name: "New display name" }).fill(
|
||||||
|
renamed,
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Save name" }).click();
|
||||||
|
await page.getByText("Workspace name saved.", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await page.locator(".saved-name").innerText(),
|
||||||
|
renamed,
|
||||||
|
);
|
||||||
|
assertEquals(await page.locator("main input:visible").count(), 0);
|
||||||
|
assertEquals(mutations.length, 1);
|
||||||
|
assertEquals(errors, []);
|
||||||
|
// Restore the synthetic name so every width actually exercises long text.
|
||||||
|
const currentResponse = await context.request.get(`${url}/api/w/home-long/settings`);
|
||||||
|
const current = await currentResponse.json();
|
||||||
|
const reset = await context.request.put(`${url}/api/w/home-long/settings`, {
|
||||||
|
data: {
|
||||||
|
display_name: metadataFixture("home-long").display_name,
|
||||||
|
revision: current.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals(reset.status(), 200);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await page.goto(`${url}/w/home-empty/settings`);
|
||||||
|
await ready(page);
|
||||||
|
await page.getByText("Not provisioned", { exact: true }).waitFor();
|
||||||
|
await page.getByRole("button", { name: "Provision identity" }).click();
|
||||||
|
await page.getByRole("button", { name: "Copy bundle" }).waitFor();
|
||||||
|
await page.goto(`${url}/w/home-error/settings`);
|
||||||
|
await ready(page);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("button", { name: "Edit name" }).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("button", { name: "Retry identity" })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.goto(`${url}/w/home-owner/settings`);
|
||||||
|
await ready(page);
|
||||||
|
await page.route("**/settings", async (route) => {
|
||||||
|
if (route.request().method() === "PUT") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
body: "Workspace metadata changed. Reload the saved name.",
|
||||||
|
});
|
||||||
|
} else await route.continue();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Edit name" }).click();
|
||||||
|
await page.getByRole("textbox", { name: "New display name" }).fill(
|
||||||
|
"Keep this draft",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Save name" }).click();
|
||||||
|
await page.getByRole("alert").waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("textbox", { name: "New display name" })
|
||||||
|
.inputValue(),
|
||||||
|
"Keep this draft",
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: join(evidence, "save-error-mobile.png"),
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Reload saved name" }).click();
|
||||||
|
await page.getByRole("button", { name: "Edit name" }).waitFor();
|
||||||
|
assertEquals(await page.getByRole("alert").count(), 0);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||||
|
}
|
||||||
|
await server.status;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts"
|
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts"
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 100,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "workspace-settings",
|
||||||
|
"title": "Workspace Identity settings disclosure",
|
||||||
|
"baseUrl": "http://127.0.0.1:15282",
|
||||||
|
"colorScheme": "light",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [
|
||||||
|
{
|
||||||
|
"id": "fixture",
|
||||||
|
"label": "Synthetic Workspace permission projections",
|
||||||
|
"auth": {
|
||||||
|
"kind": "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{
|
||||||
|
"label": "desktop",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "narrow",
|
||||||
|
"width": 768,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "mobile",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"id": "owner",
|
||||||
|
"label": "Owner settings",
|
||||||
|
"path": "/w/home-owner/settings",
|
||||||
|
"goal": "Read saved name and identity state without permanent edit forms or public JSON.",
|
||||||
|
"dataState": "Active public identity and saved name.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Saved settings"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "member",
|
||||||
|
"label": "Member settings",
|
||||||
|
"path": "/w/home-member/settings",
|
||||||
|
"goal": "Keep permitted metadata editing without owner identity or deletion controls.",
|
||||||
|
"dataState": "All owner permissions false; metadata read/write remains available under existing Backend contract.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Member view"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "long",
|
||||||
|
"label": "Long name",
|
||||||
|
"path": "/w/home-long/settings",
|
||||||
|
"goal": "Keep controls in the viewport and preserve complete long names.",
|
||||||
|
"dataState": "Long Workspace display name and active identity.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Long name"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pending",
|
||||||
|
"label": "Pending identity",
|
||||||
|
"path": "/w/home-empty/settings",
|
||||||
|
"goal": "Show provisioning attention without pretending the identity is active.",
|
||||||
|
"dataState": "Identity pending_provisioning; metadata available.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Provisioning required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "error",
|
||||||
|
"label": "Identity failure",
|
||||||
|
"path": "/w/home-error/settings",
|
||||||
|
"goal": "Keep name usable when public identity fails and offer scoped recovery.",
|
||||||
|
"dataState": "Intentional signing identity HTTP503 with its expected console diagnostic.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Scoped error"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"id": "settings-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/workspace_home_fixture_server.ts",
|
||||||
|
"15282",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15282/health"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
|
|||||||
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
|
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
|
||||||
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
||||||
- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation.
|
- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation.
|
||||||
|
- `src/lib/workspace/settings/WorkspaceIdentitySettings.svelte`: Workspace-scoped name editing, public identity disclosure, and deletion confirmation.
|
||||||
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
||||||
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
||||||
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidate, goto } from '$app/navigation';
|
||||||
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import type {
|
||||||
|
Diagnostic, WorkspaceMetadataSettingsResponse, WorkspaceSigningIdentityResponse,
|
||||||
|
WorkspaceDeletionPreflightResponse, WorkspaceDeletionOperationResponse, WorkspaceDeletionRequest,
|
||||||
|
} from '$lib/generated/legacy-server-api';
|
||||||
|
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
|
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||||
|
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
|
||||||
|
import { fetchWorkspaceMetadata, updateWorkspaceMetadata, fetchWorkspaceSigningIdentity, provisionWorkspaceSigningIdentity } from './profile-api';
|
||||||
|
import { preflightWorkspaceDeletion, startWorkspaceDeletion, getWorkspaceDeletion } from './workspace-deletion-api';
|
||||||
|
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
||||||
|
|
||||||
|
// The route keys this component by Workspace and owner permission. Requests from
|
||||||
|
// a disposed scope must not publish state, focus, or navigation into a new one.
|
||||||
|
let { workspaceId, workspaceName, canDelete }: { workspaceId: string; workspaceName: string; canDelete: boolean } = $props();
|
||||||
|
let disposed = false;
|
||||||
|
onDestroy(() => { disposed = true; });
|
||||||
|
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let metadataError = $state<string | null>(null);
|
||||||
|
let diagnostics = $state<Diagnostic[]>([]);
|
||||||
|
let editing = $state(false);
|
||||||
|
let displayNameDraft = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let nameError = $state<string | null>(null);
|
||||||
|
let nameNotice = $state<string | null>(null);
|
||||||
|
let editButton = $state<HTMLButtonElement>();
|
||||||
|
let identityCopyButton = $state<HTMLButtonElement>();
|
||||||
|
let provisionButton = $state<HTMLButtonElement>();
|
||||||
|
|
||||||
|
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
|
||||||
|
let identityLoading = $state(true);
|
||||||
|
let identityError = $state<string | null>(null);
|
||||||
|
let provisioningIdentity = $state(false);
|
||||||
|
let copyError = $state<string | null>(null);
|
||||||
|
let identityCopied = $state(false);
|
||||||
|
let identityBundleText = $derived(signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : '');
|
||||||
|
|
||||||
|
let dangerOpen = $state(false);
|
||||||
|
let dangerSummary = $state<HTMLElement>();
|
||||||
|
let deletionOpen = $state(false);
|
||||||
|
let deletionLoading = $state(false);
|
||||||
|
let deletionSubmitting = $state(false);
|
||||||
|
let deletionConfirmation = $state('');
|
||||||
|
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
|
||||||
|
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
|
||||||
|
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
|
||||||
|
let deletionError = $state<string | null>(null);
|
||||||
|
const errorText = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback;
|
||||||
|
const focusInput = (element: HTMLElement) => { element.focus(); };
|
||||||
|
const deletionStorageKey = () => `yoi:workspace-deletion:${workspaceId}`;
|
||||||
|
|
||||||
|
async function loadMetadata() {
|
||||||
|
loading = true;
|
||||||
|
metadataError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetchWorkspaceMetadata(workspaceId);
|
||||||
|
if (disposed) return;
|
||||||
|
workspaceMetadata = response;
|
||||||
|
displayNameDraft = response.display_name;
|
||||||
|
diagnostics = response.diagnostics;
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) metadataError = errorText(error, 'Workspace settings unavailable.');
|
||||||
|
} finally {
|
||||||
|
if (!disposed) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadIdentity() {
|
||||||
|
if (!canDelete) return;
|
||||||
|
identityLoading = true;
|
||||||
|
identityError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetchWorkspaceSigningIdentity(workspaceId);
|
||||||
|
if (!disposed) signingIdentity = response;
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) identityError = errorText(error, 'Public identity unavailable.');
|
||||||
|
} finally {
|
||||||
|
if (!disposed) identityLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function retryMetadata() {
|
||||||
|
await loadMetadata();
|
||||||
|
await tick();
|
||||||
|
if (!disposed && !metadataError) editButton?.focus();
|
||||||
|
}
|
||||||
|
async function retryIdentity() {
|
||||||
|
await loadIdentity();
|
||||||
|
await tick();
|
||||||
|
if (!disposed && !identityError) (identityCopyButton ?? provisionButton)?.focus();
|
||||||
|
}
|
||||||
|
function editName() {
|
||||||
|
if (!workspaceMetadata) return;
|
||||||
|
displayNameDraft = workspaceMetadata.display_name;
|
||||||
|
nameError = nameNotice = null;
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
async function cancelName() {
|
||||||
|
editing = false;
|
||||||
|
nameError = null;
|
||||||
|
displayNameDraft = workspaceMetadata?.display_name ?? '';
|
||||||
|
await tick();
|
||||||
|
if (!disposed) editButton?.focus();
|
||||||
|
}
|
||||||
|
async function reloadName() {
|
||||||
|
await loadMetadata();
|
||||||
|
if (!disposed && !metadataError) await cancelName();
|
||||||
|
}
|
||||||
|
async function submitWorkspaceName() {
|
||||||
|
if (!workspaceMetadata || submitting || !displayNameDraft.trim()) return;
|
||||||
|
submitting = true;
|
||||||
|
nameError = nameNotice = null;
|
||||||
|
try {
|
||||||
|
const response = await updateWorkspaceMetadata(workspaceId, {
|
||||||
|
display_name: displayNameDraft, revision: workspaceMetadata.revision,
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
workspaceMetadata = response.workspace;
|
||||||
|
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
|
||||||
|
nameNotice = 'Workspace name saved.';
|
||||||
|
await cancelName();
|
||||||
|
if (disposed) return;
|
||||||
|
try { await invalidate(workspaceApiPath(workspaceId, '/workspace')); }
|
||||||
|
catch { if (!disposed) nameNotice = 'Workspace name saved. Reload the page to update navigation.'; }
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) nameError = errorText(error, 'Workspace name could not be saved.');
|
||||||
|
} finally {
|
||||||
|
if (!disposed) submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function provisionIdentity() {
|
||||||
|
if (!canDelete || provisioningIdentity) return;
|
||||||
|
provisioningIdentity = true;
|
||||||
|
identityError = null;
|
||||||
|
try {
|
||||||
|
const response = await provisionWorkspaceSigningIdentity(workspaceId);
|
||||||
|
if (disposed) return;
|
||||||
|
signingIdentity = response;
|
||||||
|
await tick();
|
||||||
|
if (!disposed) identityCopyButton?.focus();
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) identityError = errorText(error, 'Identity provisioning failed.');
|
||||||
|
} finally {
|
||||||
|
if (!disposed) provisioningIdentity = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function copyIdentityBundle() {
|
||||||
|
if (!identityBundleText) return;
|
||||||
|
copyError = null;
|
||||||
|
identityCopied = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(identityBundleText);
|
||||||
|
if (!disposed) identityCopied = true;
|
||||||
|
} catch {
|
||||||
|
if (!disposed) copyError = 'Could not copy. Open Public bundle and key details to select the bundle manually.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDeletionConfirmation() {
|
||||||
|
if (!canDelete || editing || submitting || provisioningIdentity || deletionLoading || deletionSubmitting) return;
|
||||||
|
deletionOpen = true;
|
||||||
|
deletionLoading = true;
|
||||||
|
deletionError = null;
|
||||||
|
deletionPreflight = deletionOperation = deletionRequest = null;
|
||||||
|
deletionConfirmation = '';
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(deletionStorageKey());
|
||||||
|
const response = await preflightWorkspaceDeletion(workspaceId);
|
||||||
|
if (!disposed) deletionPreflight = response;
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) deletionError = errorText(error, 'Workspace deletion preflight failed.');
|
||||||
|
} finally {
|
||||||
|
if (!disposed) deletionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function cancelDeletion() {
|
||||||
|
deletionOpen = dangerOpen = false;
|
||||||
|
await tick();
|
||||||
|
if (!disposed) dangerSummary?.focus();
|
||||||
|
}
|
||||||
|
async function trackDeletion(operationId: string) {
|
||||||
|
let operation = await getWorkspaceDeletion(operationId);
|
||||||
|
if (disposed) return;
|
||||||
|
deletionOperation = operation;
|
||||||
|
while (operation.state === 'queued' || operation.state === 'running') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
if (disposed) return;
|
||||||
|
operation = await getWorkspaceDeletion(operation.operation_id);
|
||||||
|
if (disposed) return;
|
||||||
|
deletionOperation = operation;
|
||||||
|
}
|
||||||
|
if (operation.state === 'succeeded') {
|
||||||
|
sessionStorage.removeItem(deletionStorageKey());
|
||||||
|
disposeWorkspaceMultiplexer(workspaceId);
|
||||||
|
disposeWorkspaceWorkersStore(workspaceId);
|
||||||
|
await goto('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
|
||||||
|
if (typeof value !== 'object' || value === null) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
|
||||||
|
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
|
||||||
|
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
|
||||||
|
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
|
||||||
|
typeof record.confirmation !== 'string' || record.confirmation !== workspaceName || record.confirmation.length > 256
|
||||||
|
) return null;
|
||||||
|
return { operation_id: record.operation_id, expected_revision: record.expected_revision, confirmation: record.confirmation };
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
void loadMetadata();
|
||||||
|
void loadIdentity();
|
||||||
|
if (!canDelete) return;
|
||||||
|
const request = storedDeletionRequest();
|
||||||
|
if (!request) return;
|
||||||
|
deletionRequest = request;
|
||||||
|
deletionConfirmation = request.confirmation;
|
||||||
|
dangerOpen = deletionOpen = deletionSubmitting = true;
|
||||||
|
void trackDeletion(request.operation_id)
|
||||||
|
.catch((error) => { if (!disposed) deletionError = errorText(error, 'Workspace deletion status failed.'); })
|
||||||
|
.finally(() => { if (!disposed) deletionSubmitting = false; });
|
||||||
|
});
|
||||||
|
async function deleteWorkspace() {
|
||||||
|
if (!canDelete || deletionSubmitting || (!deletionPreflight && !deletionRequest)) return;
|
||||||
|
if (!deletionRequest && (!deletionPreflight?.can_delete || deletionConfirmation !== deletionPreflight.display_name)) return;
|
||||||
|
deletionSubmitting = true;
|
||||||
|
deletionError = null;
|
||||||
|
try {
|
||||||
|
const request = deletionRequest ?? {
|
||||||
|
operation_id: crypto.randomUUID(), expected_revision: deletionPreflight!.expected_revision, confirmation: deletionConfirmation,
|
||||||
|
};
|
||||||
|
deletionRequest = request;
|
||||||
|
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
|
||||||
|
const operation = await startWorkspaceDeletion(workspaceId, request);
|
||||||
|
if (disposed) return;
|
||||||
|
deletionOperation = operation;
|
||||||
|
await trackDeletion(operation.operation_id);
|
||||||
|
} catch (error) {
|
||||||
|
if (!disposed) deletionError = errorText(error, 'Workspace deletion failed.');
|
||||||
|
} finally {
|
||||||
|
if (!disposed) deletionSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="identity-settings">
|
||||||
|
<h1 id="workspace-settings-title">Workspace Identity</h1>
|
||||||
|
<section aria-labelledby="workspace-name-title" data-name-ready={!loading}>
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="workspace-name-title">Display name</h2>
|
||||||
|
{#if workspaceMetadata && !editing}
|
||||||
|
<button bind:this={editButton} type="button" disabled={deletionOpen || deletionSubmitting} onclick={editName}>Edit name</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if loading}<p class="muted" role="status">Loading workspace settings…</p>{/if}
|
||||||
|
{#if metadataError}
|
||||||
|
<p class="error" role="alert">{metadataError}</p>
|
||||||
|
<button type="button" onclick={retryMetadata} disabled={loading}>Retry name</button>
|
||||||
|
{/if}
|
||||||
|
{#if workspaceMetadata}
|
||||||
|
{#if editing}
|
||||||
|
<form onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }} aria-label="Edit Workspace name">
|
||||||
|
<label>New display name<input use:focusInput bind:value={displayNameDraft} required autocomplete="off" disabled={submitting || loading} aria-invalid={Boolean(nameError)} /></label>
|
||||||
|
<p class="muted">Current: {workspaceMetadata.display_name}</p>
|
||||||
|
{#if nameError}<p class="error" role="alert">{nameError}</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" type="submit" disabled={submitting || loading || !displayNameDraft.trim() || displayNameDraft === workspaceMetadata.display_name}>{submitting ? 'Saving…' : 'Save name'}</button>
|
||||||
|
<button type="button" disabled={submitting || loading} onclick={cancelName}>Cancel</button>
|
||||||
|
{#if nameError}<button type="button" disabled={submitting || loading} onclick={reloadName}>Reload saved name</button>{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="saved-name">{workspaceMetadata.display_name}</p>
|
||||||
|
{/if}
|
||||||
|
{#if nameNotice}<p role="status">{nameNotice}</p>{/if}
|
||||||
|
{#each diagnostics.filter((item) => item.severity !== 'info') as diagnostic}
|
||||||
|
<p class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'} role="alert">{diagnostic.message}</p>
|
||||||
|
{/each}
|
||||||
|
<details class="technical">
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Source</dt><dd><code>{workspaceMetadata.source}</code></dd></div>
|
||||||
|
<div><dt>Revision</dt><dd><code>{workspaceMetadata.revision}</code></dd></div>
|
||||||
|
</dl>
|
||||||
|
{#each diagnostics.filter((item) => item.severity === 'info') as diagnostic}<p>{diagnostic.message}</p>{/each}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if canDelete}
|
||||||
|
<BevelLine decorative />
|
||||||
|
<section aria-labelledby="workspace-identity-title" data-identity-ready={!identityLoading}>
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="workspace-identity-title">Public identity</h2>
|
||||||
|
{#if signingIdentity?.public_bundle}<button bind:this={identityCopyButton} type="button" onclick={copyIdentityBundle}>Copy bundle</button>{/if}
|
||||||
|
</div>
|
||||||
|
{#if identityLoading}<p class="muted" role="status">Loading identity…</p>{/if}
|
||||||
|
{#if identityError}<p class="error" role="alert">{identityError}</p>{/if}
|
||||||
|
{#if !identityLoading && !signingIdentity}<button type="button" onclick={retryIdentity}>Retry identity</button>{/if}
|
||||||
|
{#if signingIdentity?.identity.state === 'pending_provisioning'}
|
||||||
|
<p class="warning">Not provisioned</p>
|
||||||
|
<p class="muted">Provision a signing identity before connecting a Runtime.</p>
|
||||||
|
<button bind:this={provisionButton} type="button" disabled={provisioningIdentity || deletionOpen || deletionSubmitting} onclick={provisionIdentity}>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
|
||||||
|
{:else if signingIdentity?.public_bundle}
|
||||||
|
<p>Active</p>
|
||||||
|
{#if identityCopied}<p role="status">Public bundle copied.</p>{/if}
|
||||||
|
{#if copyError}<p class="error" role="alert">{copyError}</p>{/if}
|
||||||
|
<details class="technical">
|
||||||
|
<summary>Public bundle and key details</summary>
|
||||||
|
<p>Use this public bundle when connecting a Runtime. It contains no private key material.</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Key</dt><dd><code>{signingIdentity.identity.key_id}</code></dd></div>
|
||||||
|
<div><dt>Fingerprint</dt><dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd></div>
|
||||||
|
<div><dt>Revision</dt><dd><code>{signingIdentity.identity.revision}</code></dd></div>
|
||||||
|
</dl>
|
||||||
|
<label>Public identity bundle<textarea readonly rows="9" value={identityBundleText} spellcheck="false"></textarea></label>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
<BevelLine decorative />
|
||||||
|
<details class="deletion" bind:open={dangerOpen} ontoggle={(event) => { if (!event.currentTarget.open && !deletionSubmitting) deletionOpen = false; }}>
|
||||||
|
<summary bind:this={dangerSummary}>{deletionSubmitting ? 'Deleting Workspace…' : 'Delete Workspace'}</summary>
|
||||||
|
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data. This cannot be undone.</p>
|
||||||
|
{#if !deletionOpen}
|
||||||
|
<button type="button" disabled={editing || submitting || provisioningIdentity} onclick={openDeletionConfirmation}>Review deletion impact</button>
|
||||||
|
{:else}
|
||||||
|
<section aria-labelledby="delete-workspace-title">
|
||||||
|
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? workspaceMetadata?.display_name ?? workspaceName}?</h2>
|
||||||
|
{#if deletionLoading}<p role="status">Loading deletion impact…</p>{/if}
|
||||||
|
{#if deletionPreflight}
|
||||||
|
<ul>
|
||||||
|
<li>{deletionPreflight.resources.workers} Workers</li><li>{deletionPreflight.resources.workdirs} Workdirs</li>
|
||||||
|
<li>{deletionPreflight.resources.repositories} repositories</li><li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
|
||||||
|
<li>{deletionPreflight.resources.secrets} secret records</li><li>{deletionPreflight.resources.artifacts} artifacts</li>
|
||||||
|
</ul>
|
||||||
|
{#each deletionPreflight.blockers as blocker}<p class="error" role="alert">{blocker.message}</p>{/each}
|
||||||
|
{#if !deletionRequest}
|
||||||
|
<label>Type {deletionPreflight.display_name} to confirm<input use:focusInput bind:value={deletionConfirmation} autocomplete="off" disabled={deletionSubmitting || !deletionPreflight.can_delete} /></label>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if deletionOperation}
|
||||||
|
<p role="status">Deletion state: {deletionOperation.state}</p>
|
||||||
|
{#each deletionOperation.blockers as blocker}<p class="error" role="alert">{blocker.message}</p>{/each}
|
||||||
|
{/if}
|
||||||
|
{#if deletionError}<p class="error" role="alert">{deletionError}</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick={cancelDeletion} disabled={deletionSubmitting}>Cancel</button>
|
||||||
|
{#if deletionPreflight || deletionRequest}
|
||||||
|
<button class="danger" type="button" onclick={deleteWorkspace} disabled={deletionSubmitting || (!deletionRequest && (!deletionPreflight?.can_delete || deletionConfirmation !== deletionPreflight.display_name))}>{deletionSubmitting ? 'Deleting…' : deletionRequest ? 'Retry deletion' : 'Delete Workspace'}</button>
|
||||||
|
{:else if !deletionLoading}
|
||||||
|
<button type="button" onclick={openDeletionConfirmation}>Retry deletion check</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity-settings { display: grid; gap: var(--space-5); max-width: 52rem; min-width: 0; container-type: inline-size; }
|
||||||
|
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
|
||||||
|
h2 { margin: 0; font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); }
|
||||||
|
section, form { display: grid; gap: var(--space-3); min-width: 0; }
|
||||||
|
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
|
||||||
|
p { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.muted, .technical { color: var(--text-muted); }
|
||||||
|
.error { color: var(--danger); }
|
||||||
|
.warning { color: var(--warning); }
|
||||||
|
label { display: grid; gap: var(--space-1); min-width: 0; }
|
||||||
|
input, textarea { width: 100%; min-width: 0; border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text-strong); padding: var(--space-2); font: inherit; }
|
||||||
|
input[aria-invalid="true"] { border-color: var(--danger); }
|
||||||
|
textarea { resize: vertical; font-family: var(--font-mono); font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
|
||||||
|
code { font-family: var(--font-mono); }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
|
button { width: fit-content; max-width: 100%; border: 1px solid var(--line); border-radius: var(--radius-soft); padding: var(--space-2) var(--space-3); color: var(--text); background: transparent; cursor: pointer; font: inherit; }
|
||||||
|
button.primary { background: var(--text-strong); color: var(--bg); border-color: var(--text-strong); }
|
||||||
|
button.danger { color: var(--danger); border-color: var(--danger); }
|
||||||
|
button:hover:not(:disabled), summary:hover { background: var(--interactive-hover); color: var(--text-strong); }
|
||||||
|
button:active:not(:disabled), summary:active { background: var(--interactive-selected); color: var(--text-strong); }
|
||||||
|
button:focus-visible, summary:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
button:disabled, input:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
summary { cursor: pointer; width: fit-content; max-width: 100%; color: var(--text); padding-block: var(--space-2); }
|
||||||
|
details > :not(summary) { margin-top: var(--space-3); }
|
||||||
|
dl { display: grid; gap: var(--space-2); margin: 0; font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
|
||||||
|
dl div { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: var(--space-3); }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.deletion > summary { color: var(--danger); }
|
||||||
|
ul { margin: 0; padding-left: var(--space-5); }
|
||||||
|
@container (max-width: 28rem) { dl div { grid-template-columns: 1fr; gap: var(--space-1); } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Synthetic public data only; shared by component tests and production browser fixtures.
|
||||||
|
export function metadataFixture(workspaceId = "home-owner") {
|
||||||
|
return {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
display_name: workspaceId === "home-long"
|
||||||
|
? "Workspace with a long name — international documentation and distributed development"
|
||||||
|
: "Workspace Settings Review",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
revision: "2026-01-02T00:00:00Z",
|
||||||
|
source: "server_db",
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function identityFixture(workspaceId = "home-owner", pending = false) {
|
||||||
|
const identity = {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
key_id: "fixture-workspace-key",
|
||||||
|
algorithm: "ed25519",
|
||||||
|
revision: 1,
|
||||||
|
state: pending ? "pending_provisioning" : "active",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
if (pending) return { identity };
|
||||||
|
const publicKey = "yoi-ed25519-pub:v1:synthetic-public-key";
|
||||||
|
const fingerprint = "SHA256:synthetic-public-fingerprint-for-ui-review";
|
||||||
|
return {
|
||||||
|
identity: {
|
||||||
|
...identity,
|
||||||
|
public_key: publicKey,
|
||||||
|
public_key_fingerprint: fingerprint,
|
||||||
|
provisioned_at: identity.created_at,
|
||||||
|
},
|
||||||
|
public_bundle: {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
backend_url: "https://workspace.example.test",
|
||||||
|
key_id: identity.key_id,
|
||||||
|
algorithm: identity.algorithm,
|
||||||
|
public_key: publicKey,
|
||||||
|
public_key_fingerprint: fingerprint,
|
||||||
|
revision: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function deletionFixture(workspaceId = "home-owner") {
|
||||||
|
return {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
display_name: metadataFixture(workspaceId).display_name,
|
||||||
|
expected_revision: metadataFixture(workspaceId).revision,
|
||||||
|
can_delete: true,
|
||||||
|
blockers: [],
|
||||||
|
resources: {
|
||||||
|
workers: 2,
|
||||||
|
workdirs: 1,
|
||||||
|
repositories: 1,
|
||||||
|
runtime_bindings: 1,
|
||||||
|
secrets: 0,
|
||||||
|
artifacts: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function settingsFixtureHandler() {
|
||||||
|
const names = new Map<string, ReturnType<typeof metadataFixture>>();
|
||||||
|
const provisioned = new Set<string>();
|
||||||
|
return async (request: Request): Promise<Response | null> => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const match = url.pathname.match(/^\/api\/w\/([^/]+)\/settings(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, id, path] = match;
|
||||||
|
if (!path) {
|
||||||
|
const current = names.get(id) ?? metadataFixture(id);
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const body = await request.json();
|
||||||
|
if (body.revision !== current.revision) {
|
||||||
|
return new Response(
|
||||||
|
"Workspace metadata changed. Reload the saved name.",
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
display_name: String(body.display_name).trim(),
|
||||||
|
revision: String(Number.parseInt(current.revision, 10) + 1),
|
||||||
|
};
|
||||||
|
names.set(id, next);
|
||||||
|
return Response.json({ workspace: next, diagnostics: [] });
|
||||||
|
}
|
||||||
|
return Response.json(current);
|
||||||
|
}
|
||||||
|
if (path === "/signing-identity/provision") {
|
||||||
|
provisioned.add(id);
|
||||||
|
return Response.json(identityFixture(id));
|
||||||
|
}
|
||||||
|
if (path === "/signing-identity") {
|
||||||
|
if (id === "home-error") {
|
||||||
|
return new Response("Public identity is temporarily unavailable.", {
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json(
|
||||||
|
identityFixture(id, id === "home-empty" && !provisioned.has(id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deletion = url.pathname.match(
|
||||||
|
/^\/api\/workspaces\/([^/]+)\/deletion$/,
|
||||||
|
);
|
||||||
|
if (deletion) {
|
||||||
|
if (request.method !== "GET") {
|
||||||
|
return new Response("Fixture does not delete Workspaces.", {
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
...deletionFixture(deletion[1]),
|
||||||
|
...(names.has(deletion[1])
|
||||||
|
? {
|
||||||
|
display_name: names.get(deletion[1])!.display_name,
|
||||||
|
expected_revision: names.get(deletion[1])!.revision,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,395 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import WorkspaceIdentitySettings from '$lib/workspace/settings/WorkspaceIdentitySettings.svelte';
|
||||||
Diagnostic,
|
|
||||||
WorkspaceDeletionOperationResponse,
|
|
||||||
WorkspaceDeletionPreflightResponse,
|
|
||||||
WorkspaceDeletionRequest,
|
|
||||||
WorkspaceMetadataSettingsResponse,
|
|
||||||
WorkspaceSigningIdentityResponse,
|
|
||||||
} from '$lib/generated/legacy-server-api';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
|
||||||
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
|
|
||||||
import {
|
|
||||||
getWorkspaceDeletion,
|
|
||||||
preflightWorkspaceDeletion,
|
|
||||||
startWorkspaceDeletion,
|
|
||||||
} from '$lib/workspace/settings/workspace-deletion-api';
|
|
||||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
|
||||||
import {
|
|
||||||
fetchWorkspaceMetadata,
|
|
||||||
fetchWorkspaceSigningIdentity,
|
|
||||||
provisionWorkspaceSigningIdentity,
|
|
||||||
updateWorkspaceMetadata,
|
|
||||||
} from '$lib/workspace/settings/profile-api';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
|
||||||
|
|
||||||
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
|
|
||||||
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
|
|
||||||
let identityLoading = $state(true);
|
|
||||||
let identityError = $state<string | null>(null);
|
|
||||||
let provisioningIdentity = $state(false);
|
|
||||||
let identityCopied = $state(false);
|
|
||||||
let identityBundleText = $derived(
|
|
||||||
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
|
|
||||||
);
|
|
||||||
let displayNameDraft = $state('');
|
|
||||||
let loading = $state(true);
|
|
||||||
let submitting = $state(false);
|
|
||||||
let message = $state<string | null>(null);
|
|
||||||
let diagnostics = $state<Diagnostic[]>([]);
|
|
||||||
let deletionOpen = $state(false);
|
|
||||||
let deletionLoading = $state(false);
|
|
||||||
let deletionSubmitting = $state(false);
|
|
||||||
let deletionConfirmation = $state('');
|
|
||||||
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
|
|
||||||
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
|
|
||||||
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
|
|
||||||
let deletionError = $state<string | null>(null);
|
|
||||||
function deletionStorageKey(): string {
|
|
||||||
return `yoi:workspace-deletion:${workspaceId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!workspaceId) {
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
const response = await fetchWorkspaceMetadata(workspaceId);
|
|
||||||
if (!cancelled) {
|
|
||||||
workspaceMetadata = response;
|
|
||||||
displayNameDraft = response.display_name;
|
|
||||||
diagnostics = response.diagnostics;
|
|
||||||
if (data.workspace?.permissions.delete_workspace) {
|
|
||||||
try {
|
|
||||||
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
|
|
||||||
} catch (err) {
|
|
||||||
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
|
|
||||||
} finally {
|
|
||||||
identityLoading = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
identityLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
message = err instanceof Error ? err.message : 'workspace settings request failed';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function submitWorkspaceName() {
|
|
||||||
if (!workspaceMetadata) return;
|
|
||||||
submitting = true;
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
const response = await updateWorkspaceMetadata(workspaceId, {
|
|
||||||
display_name: displayNameDraft,
|
|
||||||
revision: workspaceMetadata.revision
|
|
||||||
});
|
|
||||||
workspaceMetadata = response.workspace;
|
|
||||||
displayNameDraft = response.workspace.display_name;
|
|
||||||
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
|
|
||||||
message = 'Workspace display name updated.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'workspace update failed';
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function provisionIdentity() {
|
|
||||||
provisioningIdentity = true;
|
|
||||||
identityError = null;
|
|
||||||
try {
|
|
||||||
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
|
|
||||||
} catch (err) {
|
|
||||||
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
|
|
||||||
} finally {
|
|
||||||
provisioningIdentity = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyIdentityBundle() {
|
|
||||||
const bundle = signingIdentity?.public_bundle;
|
|
||||||
if (!bundle) return;
|
|
||||||
identityCopied = false;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
|
|
||||||
identityCopied = true;
|
|
||||||
} catch (err) {
|
|
||||||
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDeletionConfirmation() {
|
|
||||||
deletionOpen = true;
|
|
||||||
deletionLoading = true;
|
|
||||||
deletionError = null;
|
|
||||||
deletionOperation = null;
|
|
||||||
deletionRequest = null;
|
|
||||||
sessionStorage.removeItem(deletionStorageKey());
|
|
||||||
deletionConfirmation = '';
|
|
||||||
try {
|
|
||||||
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
|
|
||||||
} catch (err) {
|
|
||||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
|
|
||||||
} finally {
|
|
||||||
deletionLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function trackDeletion(operationId: string) {
|
|
||||||
let operation = await getWorkspaceDeletion(operationId);
|
|
||||||
deletionOperation = operation;
|
|
||||||
while (operation.state === 'queued' || operation.state === 'running') {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
operation = await getWorkspaceDeletion(operation.operation_id);
|
|
||||||
deletionOperation = operation;
|
|
||||||
}
|
|
||||||
if (operation.state === 'succeeded') {
|
|
||||||
sessionStorage.removeItem(deletionStorageKey());
|
|
||||||
disposeWorkspaceMultiplexer(workspaceId);
|
|
||||||
disposeWorkspaceWorkersStore(workspaceId);
|
|
||||||
await goto('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
|
|
||||||
try {
|
|
||||||
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
|
|
||||||
if (typeof value !== 'object' || value === null) return null;
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
|
|
||||||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
|
|
||||||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
|
|
||||||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
|
|
||||||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
|
|
||||||
) return null;
|
|
||||||
return {
|
|
||||||
operation_id: record.operation_id,
|
|
||||||
expected_revision: record.expected_revision,
|
|
||||||
confirmation: record.confirmation,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!data.workspace?.permissions.delete_workspace) return;
|
|
||||||
const request = storedDeletionRequest();
|
|
||||||
if (!request) return;
|
|
||||||
deletionRequest = request;
|
|
||||||
deletionConfirmation = request.confirmation;
|
|
||||||
deletionOpen = true;
|
|
||||||
deletionSubmitting = true;
|
|
||||||
void trackDeletion(request.operation_id)
|
|
||||||
.catch((err) => {
|
|
||||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
deletionSubmitting = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function deleteWorkspace() {
|
|
||||||
if (!deletionPreflight && !deletionRequest) return;
|
|
||||||
deletionSubmitting = true;
|
|
||||||
deletionError = null;
|
|
||||||
try {
|
|
||||||
const request = deletionRequest ?? {
|
|
||||||
operation_id: crypto.randomUUID(),
|
|
||||||
expected_revision: deletionPreflight!.expected_revision,
|
|
||||||
confirmation: deletionConfirmation,
|
|
||||||
};
|
|
||||||
deletionRequest = request;
|
|
||||||
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
|
|
||||||
const operation = await startWorkspaceDeletion(workspaceId, request);
|
|
||||||
deletionOperation = operation;
|
|
||||||
await trackDeletion(operation.operation_id);
|
|
||||||
} catch (err) {
|
|
||||||
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
|
|
||||||
} finally {
|
|
||||||
deletionSubmitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head><title>Workspace Identity · Settings · Yoi</title></svelte:head>
|
||||||
<title>Workspace settings · Yoi Workspace</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card settings-section" aria-labelledby="workspace-settings-title">
|
{#if data.workspace}
|
||||||
<header class="settings-section-header">
|
{#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`}
|
||||||
<div>
|
<WorkspaceIdentitySettings
|
||||||
<p class="eyebrow">editable</p>
|
workspaceId={data.workspace.workspace_id}
|
||||||
<h2 id="workspace-settings-title">Workspace Identity</h2>
|
workspaceName={data.workspace.display_name}
|
||||||
</div>
|
canDelete={data.workspace.permissions.delete_workspace}
|
||||||
<span class="badge success">Backend scoped</span>
|
/>
|
||||||
</header>
|
{/key}
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="status-message">Loading workspace settings…</p>
|
|
||||||
{:else}
|
|
||||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
|
|
||||||
<label>
|
|
||||||
<span>Display name</span>
|
|
||||||
<input bind:value={displayNameDraft} autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
|
|
||||||
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<dl class="settings-identity-list">
|
|
||||||
<div>
|
|
||||||
<dt>Source</dt>
|
|
||||||
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Revision</dt>
|
|
||||||
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if message}
|
|
||||||
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
|
|
||||||
{/if}
|
|
||||||
<DiagnosticsList {diagnostics} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if data.workspace?.permissions.delete_workspace}
|
|
||||||
<section class="settings-section" aria-labelledby="workspace-identity-title">
|
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<h2 id="workspace-identity-title">Workspace public identity</h2>
|
|
||||||
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
|
|
||||||
</div>
|
|
||||||
{#if signingIdentity?.public_bundle}
|
|
||||||
<button type="button" onclick={() => void copyIdentityBundle()}>
|
|
||||||
{identityCopied ? 'Copied' : 'Copy bundle'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if identityError}
|
|
||||||
<p class="status-message error">{identityError}</p>
|
|
||||||
{/if}
|
|
||||||
{#if identityLoading}
|
|
||||||
<p>Loading identity…</p>
|
|
||||||
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
|
|
||||||
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={provisioningIdentity}
|
|
||||||
onclick={() => void provisionIdentity()}
|
|
||||||
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
|
|
||||||
{:else if signingIdentity?.public_bundle}
|
|
||||||
<dl class="metadata-list">
|
|
||||||
<div>
|
|
||||||
<dt>Key</dt>
|
|
||||||
<dd><code>{signingIdentity.identity.key_id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Fingerprint</dt>
|
|
||||||
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Revision</dt>
|
|
||||||
<dd><code>{signingIdentity.identity.revision}</code></dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<label class="identity-bundle">
|
|
||||||
<span>Public identity bundle</span>
|
|
||||||
<textarea readonly rows="9" value={identityBundleText}></textarea>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
|
|
||||||
<div>
|
|
||||||
<h2 id="workspace-danger-title">Danger zone</h2>
|
|
||||||
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
|
|
||||||
</div>
|
|
||||||
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if deletionOpen}
|
|
||||||
<div class="modal-backdrop" role="presentation">
|
|
||||||
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
|
|
||||||
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
|
|
||||||
{#if deletionLoading}
|
|
||||||
<p>Loading deletion impact…</p>
|
|
||||||
{:else if deletionPreflight}
|
|
||||||
<p>This operation cannot be undone. It will remove:</p>
|
|
||||||
<ul>
|
|
||||||
<li>{deletionPreflight.resources.workers} Workers</li>
|
|
||||||
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
|
|
||||||
<li>{deletionPreflight.resources.repositories} repositories</li>
|
|
||||||
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
|
|
||||||
<li>{deletionPreflight.resources.secrets} secret records</li>
|
|
||||||
<li>{deletionPreflight.resources.artifacts} artifacts</li>
|
|
||||||
</ul>
|
|
||||||
{#each deletionPreflight.blockers as blocker}
|
|
||||||
<p class="status-message error">{blocker.message}</p>
|
|
||||||
{/each}
|
|
||||||
<label>
|
|
||||||
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
|
|
||||||
<input bind:value={deletionConfirmation} autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
{#if deletionOperation}
|
|
||||||
<p class="status-message">Deletion state: {deletionOperation.state}</p>
|
|
||||||
{#each deletionOperation.blockers as blocker}
|
|
||||||
<p class="status-message error">{blocker.message}</p>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
|
|
||||||
<button
|
|
||||||
class="danger-button"
|
|
||||||
type="button"
|
|
||||||
onclick={() => void deleteWorkspace()}
|
|
||||||
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
|
|
||||||
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
|
|
||||||
.section-heading p { margin-block: var(--space-1) 0; }
|
|
||||||
.metadata-list { display: grid; gap: var(--space-2); }
|
|
||||||
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
|
|
||||||
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
|
|
||||||
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
|
|
||||||
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: var(--font-size-compact); }
|
|
||||||
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
|
|
||||||
.danger-zone p { max-width: 68ch; }
|
|
||||||
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
|
|
||||||
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
|
|
||||||
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
|
|
||||||
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
|
|
||||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { goto, invalidate } from "$app/navigation";
|
||||||
|
import Settings from "./+page.svelte";
|
||||||
|
import {
|
||||||
|
deletionFixture,
|
||||||
|
identityFixture,
|
||||||
|
metadataFixture,
|
||||||
|
settingsFixtureHandler,
|
||||||
|
} from "$lib/workspace/settings/identity.test-fixtures";
|
||||||
|
vi.mock(
|
||||||
|
"$app/navigation",
|
||||||
|
() => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }),
|
||||||
|
);
|
||||||
|
vi.mock(
|
||||||
|
"$lib/workspace/multiplexer",
|
||||||
|
() => ({ disposeWorkspaceMultiplexer: vi.fn() }),
|
||||||
|
);
|
||||||
|
vi.mock(
|
||||||
|
"$lib/workspace/sidebar/worker-subscription",
|
||||||
|
() => ({ disposeWorkspaceWorkersStore: vi.fn() }),
|
||||||
|
);
|
||||||
|
type Data = ComponentProps<typeof Settings>["data"];
|
||||||
|
function data(id = "home-owner", owner = true): Data {
|
||||||
|
return {
|
||||||
|
workspace: {
|
||||||
|
workspace_id: id,
|
||||||
|
display_name: metadataFixture(id).display_name,
|
||||||
|
permissions: { delete_workspace: owner },
|
||||||
|
},
|
||||||
|
} as Data;
|
||||||
|
}
|
||||||
|
function mount(value = data()) {
|
||||||
|
return render(Settings, {
|
||||||
|
props: {
|
||||||
|
data: value,
|
||||||
|
params: { workspaceId: value.workspace!.workspace_id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function mockApi(
|
||||||
|
override?: (request: Request) => Promise<Response | null> | Response | null,
|
||||||
|
) {
|
||||||
|
const handler = settingsFixtureHandler();
|
||||||
|
const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const request = new Request(
|
||||||
|
new URL(String(input), "https://fixture.test"),
|
||||||
|
init,
|
||||||
|
);
|
||||||
|
return await override?.(request) ?? await handler(request) ??
|
||||||
|
new Response("Unexpected fixture request", { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", mock);
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
async function openName() {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Edit name" }),
|
||||||
|
);
|
||||||
|
return screen.getByLabelText("New display name") as HTMLInputElement;
|
||||||
|
}
|
||||||
|
async function openDeletion() {
|
||||||
|
await screen.findByText("Active", { exact: true });
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByText("Delete Workspace", { selector: "summary" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Review deletion impact" }),
|
||||||
|
);
|
||||||
|
return await screen.findByLabelText(
|
||||||
|
`Type ${metadataFixture().display_name} to confirm`,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
}
|
||||||
|
function operation(state = "succeeded", operationId = "op-test") {
|
||||||
|
return {
|
||||||
|
operation_id: operationId,
|
||||||
|
workspace_id: "home-owner",
|
||||||
|
display_name: metadataFixture().display_name,
|
||||||
|
state,
|
||||||
|
resources: deletionFixture().resources,
|
||||||
|
child_operation_ids: [],
|
||||||
|
blockers: [],
|
||||||
|
failure_category: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
completed_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved settings have no editable form, badges or raw data expanded; each technical scope is disclosed", async () => {
|
||||||
|
const api = mockApi();
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Active", { exact: true });
|
||||||
|
expect(screen.getByText(metadataFixture().display_name)).toBeTruthy();
|
||||||
|
expect(document.querySelector("form")).toBeNull();
|
||||||
|
expect(document.querySelector("input, textarea:not([readonly])")).toBeNull();
|
||||||
|
// happy-dom does not implement native details visibility; Chromium verifies it.
|
||||||
|
expect(document.querySelector("textarea")?.closest("details")?.open).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(document.querySelectorAll("details[open]")).toHaveLength(0);
|
||||||
|
expect(document.querySelector(".badge")).toBeNull();
|
||||||
|
expect(api.mock.calls).toHaveLength(2);
|
||||||
|
expect(api.mock.calls.some(([path]) => String(path).includes("deletion")))
|
||||||
|
.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Edit focuses the field; Cancel discards the draft and returns focus without mutation", async () => {
|
||||||
|
const api = mockApi();
|
||||||
|
mount();
|
||||||
|
const input = await openName();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
await fireEvent.input(input, { target: { value: "Discard this" } });
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByRole("button", { name: "Edit name" }),
|
||||||
|
);
|
||||||
|
expect((await openName()).value).toBe(metadataFixture().display_name);
|
||||||
|
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("save uses the current revision, publishes confirmed name and invalidates the Workspace header", async () => {
|
||||||
|
const api = mockApi();
|
||||||
|
mount();
|
||||||
|
await fireEvent.input(await openName(), {
|
||||||
|
target: { value: "Renamed Workspace" },
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
|
||||||
|
await screen.findByText("Workspace name saved.");
|
||||||
|
expect(screen.getByText("Renamed Workspace")).toBeTruthy();
|
||||||
|
expect(document.querySelector("form")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByRole("button", { name: "Edit name" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
JSON.parse(String(
|
||||||
|
api.mock.calls.find(([, init]) => init?.method === "PUT")![1]!.body,
|
||||||
|
)),
|
||||||
|
).toEqual({
|
||||||
|
display_name: "Renamed Workspace",
|
||||||
|
revision: metadataFixture().revision,
|
||||||
|
});
|
||||||
|
expect(invalidate).toHaveBeenCalledWith("/api/w/home-owner/workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed save preserves the draft and saved name; Reload saved name recovers from revision conflicts", async () => {
|
||||||
|
mockApi((request) =>
|
||||||
|
request.method === "PUT"
|
||||||
|
? new Response("Revision conflict", { status: 409 })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
const input = await openName();
|
||||||
|
await fireEvent.input(input, { target: { value: "Keep draft" } });
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||||
|
"Revision conflict",
|
||||||
|
);
|
||||||
|
expect(input.value).toBe("Keep draft");
|
||||||
|
expect(screen.getByText(`Current: ${metadataFixture().display_name}`))
|
||||||
|
.toBeTruthy();
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Reload saved name" }),
|
||||||
|
);
|
||||||
|
await screen.findByRole("button", { name: "Edit name" });
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
expect((await openName()).value).toBe(metadataFixture().display_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending save disables duplicate submit and cancel while preserving the current value", async () => {
|
||||||
|
let release!: (response: Response) => void;
|
||||||
|
const held = new Promise<Response>((resolve) => release = resolve);
|
||||||
|
const api = mockApi((request) => request.method === "PUT" ? held : null);
|
||||||
|
mount();
|
||||||
|
await fireEvent.input(await openName(), { target: { value: "New name" } });
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
await fireEvent.submit(document.querySelector("form")!);
|
||||||
|
expect(api.mock.calls.filter(([, init]) => init?.method === "PUT"))
|
||||||
|
.toHaveLength(1);
|
||||||
|
release(
|
||||||
|
Response.json({
|
||||||
|
workspace: { ...metadataFixture(), display_name: "New name" },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await screen.findByText("Workspace name saved.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("metadata and public identity load independently and offer scoped retries", async () => {
|
||||||
|
let failName = true;
|
||||||
|
mockApi((request) =>
|
||||||
|
failName && new URL(request.url).pathname.endsWith("/settings")
|
||||||
|
? new Response("Name unavailable", { status: 503 })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Active", { exact: true });
|
||||||
|
await screen.findByText("Name unavailable");
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit name" })).toBeNull();
|
||||||
|
failName = false;
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Retry name" }));
|
||||||
|
await screen.findByRole("button", { name: "Edit name" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("identity failure does not block name editing or masquerade as pending provisioning", async () => {
|
||||||
|
let fail = true;
|
||||||
|
mockApi((request) =>
|
||||||
|
fail && request.url.endsWith("/signing-identity")
|
||||||
|
? new Response("Identity unavailable", { status: 503 })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByRole("button", { name: "Edit name" });
|
||||||
|
await screen.findByText("Identity unavailable");
|
||||||
|
expect(screen.queryByRole("button", { name: "Provision identity" }))
|
||||||
|
.toBeNull();
|
||||||
|
fail = false;
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Retry identity" }));
|
||||||
|
await screen.findByText("Active", { exact: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-owner retains metadata editing but never fetches or shows owner-only identity/deletion", async () => {
|
||||||
|
const api = mockApi();
|
||||||
|
mount(data("home-member", false));
|
||||||
|
await screen.findByRole("button", { name: "Edit name" });
|
||||||
|
expect(screen.queryByRole("heading", { name: "Public identity" })).toBeNull();
|
||||||
|
expect(screen.queryByText("Delete Workspace")).toBeNull();
|
||||||
|
expect(api).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending provisioning is explicit, only provisions on action and focuses the resulting copy action", async () => {
|
||||||
|
const api = mockApi();
|
||||||
|
mount(data("home-empty"));
|
||||||
|
await screen.findByText("Not provisioned");
|
||||||
|
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Provision identity" }),
|
||||||
|
);
|
||||||
|
const copy = await screen.findByRole("button", { name: "Copy bundle" });
|
||||||
|
expect(document.activeElement).toBe(copy);
|
||||||
|
expect(screen.getByText("Active", { exact: true })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copy uses the exact public bundle; clipboard failure offers manual selection rather than losing data", async () => {
|
||||||
|
mockApi();
|
||||||
|
const writeText = vi.fn().mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error("Denied"));
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText },
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
const button = await screen.findByRole("button", { name: "Copy bundle" });
|
||||||
|
await fireEvent.click(button);
|
||||||
|
await screen.findByText("Public bundle copied.");
|
||||||
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify(identityFixture().public_bundle, null, 2),
|
||||||
|
);
|
||||||
|
await fireEvent.click(button);
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||||
|
"select the bundle manually",
|
||||||
|
);
|
||||||
|
expect(document.querySelector("textarea")!.value).toBe(
|
||||||
|
JSON.stringify(identityFixture().public_bundle, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletion is deferred, requires exact current name, and Cancel restores disclosure focus", async () => {
|
||||||
|
const api = mockApi();
|
||||||
|
mount();
|
||||||
|
const input = await openDeletion();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
const confirm = screen.getByRole("button", {
|
||||||
|
name: "Delete Workspace",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(confirm.disabled).toBe(true);
|
||||||
|
await fireEvent.input(input, { target: { value: "Wrong name" } });
|
||||||
|
expect(confirm.disabled).toBe(true);
|
||||||
|
await fireEvent.input(input, {
|
||||||
|
target: { value: metadataFixture().display_name },
|
||||||
|
});
|
||||||
|
expect(confirm.disabled).toBe(false);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(document.activeElement?.textContent).toBe("Delete Workspace");
|
||||||
|
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletion blockers and preflight failure do not permit a destructive request", async () => {
|
||||||
|
let fail = true;
|
||||||
|
const api = mockApi((request) =>
|
||||||
|
request.url.endsWith("/deletion")
|
||||||
|
? fail
|
||||||
|
? new Response("Preflight unavailable", { status: 503 })
|
||||||
|
: Response.json({
|
||||||
|
...deletionFixture(),
|
||||||
|
can_delete: false,
|
||||||
|
blockers: [{
|
||||||
|
kind: "worker_removal_blocked",
|
||||||
|
resource_kind: "worker",
|
||||||
|
resource_key: "W-7",
|
||||||
|
message: "Worker W-7 is running.",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Active", { exact: true });
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByText("Delete Workspace", { selector: "summary" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Review deletion impact" }),
|
||||||
|
);
|
||||||
|
await screen.findByRole("button", { name: "Retry deletion check" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Delete Workspace" })).toBeNull();
|
||||||
|
fail = false;
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Retry deletion check" }),
|
||||||
|
);
|
||||||
|
await screen.findByText("Worker W-7 is running.");
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Delete Workspace",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletion retry reuses the persisted operation id and confirmation, then redirects only after success", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const api = mockApi(async (request) => {
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/deletion")) {
|
||||||
|
const body = await request.json();
|
||||||
|
return ++attempts === 1
|
||||||
|
? new Response("Connection lost", { status: 503 })
|
||||||
|
: Response.json(operation("queued", body.operation_id));
|
||||||
|
}
|
||||||
|
if (request.url.includes("/workspace-deletions/")) {
|
||||||
|
return Response.json(
|
||||||
|
operation("succeeded", request.url.split("/").pop()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await fireEvent.input(await openDeletion(), {
|
||||||
|
target: { value: metadataFixture().display_name },
|
||||||
|
});
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Delete Workspace" }),
|
||||||
|
);
|
||||||
|
await screen.findByRole("button", { name: "Retry deletion" });
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
const stored = sessionStorage.getItem("yoi:workspace-deletion:home-owner");
|
||||||
|
expect(stored).toBeTruthy();
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Retry deletion" }));
|
||||||
|
await waitFor(() => expect(goto).toHaveBeenCalledWith("/"));
|
||||||
|
const requests = api.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||||
|
expect(requests[0][1]!.body).toBe(requests[1][1]!.body);
|
||||||
|
expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner"))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reload resumes persisted deletion without sending another POST", async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"yoi:workspace-deletion:home-owner",
|
||||||
|
JSON.stringify({
|
||||||
|
operation_id: "op-test",
|
||||||
|
expected_revision: metadataFixture().revision,
|
||||||
|
confirmation: metadataFixture().display_name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const api = mockApi((request) =>
|
||||||
|
request.url.includes("/workspace-deletions/")
|
||||||
|
? Response.json(operation("failed"))
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByText("Deletion state: failed");
|
||||||
|
expect(document.querySelector("details.deletion")?.hasAttribute("open")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Workspace switch resets drafts and ignores old load/mutation completions", async () => {
|
||||||
|
let release!: (response: Response) => void;
|
||||||
|
const held = new Promise<Response>((resolve) => release = resolve);
|
||||||
|
mockApi((request) => request.method === "PUT" ? held : null);
|
||||||
|
const view = mount();
|
||||||
|
await fireEvent.input(await openName(), {
|
||||||
|
target: { value: "Old scope draft" },
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
|
||||||
|
await view.rerender({
|
||||||
|
data: data("home-long"),
|
||||||
|
params: { workspaceId: "home-long" },
|
||||||
|
});
|
||||||
|
await screen.findByText(metadataFixture("home-long").display_name);
|
||||||
|
release(
|
||||||
|
Response.json({
|
||||||
|
workspace: { ...metadataFixture(), display_name: "Old scope draft" },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(screen.queryByText("Old scope draft")).toBeNull();
|
||||||
|
expect(invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slow identity keeps the name usable and old identity responses cannot leak across Workspaces", async () => {
|
||||||
|
let release!: (response: Response) => void;
|
||||||
|
const held = new Promise<Response>((resolve) => release = resolve);
|
||||||
|
mockApi((request) =>
|
||||||
|
request.url.endsWith("/home-owner/settings/signing-identity") ? held : null
|
||||||
|
);
|
||||||
|
const view = mount();
|
||||||
|
await screen.findByRole("button", { name: "Edit name" });
|
||||||
|
expect(screen.getByText("Loading identity…")).toBeTruthy();
|
||||||
|
await view.rerender({
|
||||||
|
data: data("home-empty"),
|
||||||
|
params: { workspaceId: "home-empty" },
|
||||||
|
});
|
||||||
|
await screen.findByText("Not provisioned");
|
||||||
|
release(Response.json(identityFixture()));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(screen.queryByText("Active", { exact: true })).toBeNull();
|
||||||
|
expect(screen.getByText("Not provisioned")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a completed deletion request from an unmounted Workspace cannot navigate away from the new scope", async () => {
|
||||||
|
const request = {
|
||||||
|
operation_id: "op-test",
|
||||||
|
expected_revision: metadataFixture().revision,
|
||||||
|
confirmation: metadataFixture().display_name,
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"yoi:workspace-deletion:home-owner",
|
||||||
|
JSON.stringify(request),
|
||||||
|
);
|
||||||
|
let release!: (response: Response) => void;
|
||||||
|
const held = new Promise<Response>((resolve) => release = resolve);
|
||||||
|
mockApi((request) =>
|
||||||
|
request.url.includes("/workspace-deletions/") ? held : null
|
||||||
|
);
|
||||||
|
const view = mount();
|
||||||
|
await screen.findByText("Deleting…");
|
||||||
|
await view.rerender({
|
||||||
|
data: data("home-member", false),
|
||||||
|
params: { workspaceId: "home-member" },
|
||||||
|
});
|
||||||
|
await screen.findByRole("button", { name: "Edit name" });
|
||||||
|
release(Response.json(operation()));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner")).not
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("name editing and deletion confirmation cannot overlap", async () => {
|
||||||
|
mockApi();
|
||||||
|
mount();
|
||||||
|
await openName();
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByText("Delete Workspace", { selector: "summary" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Review deletion impact",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Cancel" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Review deletion impact" }),
|
||||||
|
);
|
||||||
|
await screen.findByLabelText(
|
||||||
|
`Type ${metadataFixture().display_name} to confirm`,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Cancel" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("untrusted names and errors remain text, not interpreted HTML", async () => {
|
||||||
|
const name = "<img src=x onerror=alert(1)>";
|
||||||
|
mockApi((request) =>
|
||||||
|
request.url.endsWith("/settings")
|
||||||
|
? Response.json({ ...metadataFixture(), display_name: name })
|
||||||
|
: request.url.endsWith("/signing-identity")
|
||||||
|
? new Response("<script>bad()</script>", { status: 503 })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByText(name);
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||||
|
"<script>bad()</script>",
|
||||||
|
);
|
||||||
|
expect(document.querySelector("img, script")).toBeNull();
|
||||||
|
});
|
||||||
@@ -313,11 +313,11 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st
|
|||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
|
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
|
||||||
const source = await Deno.readTextFile(
|
const source = (await Promise.all([
|
||||||
new URL(
|
|
||||||
"../src/routes/w/[workspaceId]/settings/+page.svelte",
|
"../src/routes/w/[workspaceId]/settings/+page.svelte",
|
||||||
import.meta.url,
|
"../src/lib/workspace/settings/WorkspaceIdentitySettings.svelte",
|
||||||
),
|
].map((path) => Deno.readTextFile(new URL(path, import.meta.url))))).join(
|
||||||
|
"\n",
|
||||||
);
|
);
|
||||||
for (
|
for (
|
||||||
const token of [
|
const token of [
|
||||||
|
|||||||
Reference in New Issue
Block a user