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
|
||||
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
|
||||
|
||||
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 { 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 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> = {
|
||||
"home-owner": "Workspace Home Review",
|
||||
"home-member": "Shared Workspace",
|
||||
@@ -25,6 +30,8 @@ const mime: Record<string, string> = {
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const settingsResponse = await settingsFixture(request);
|
||||
if (settingsResponse) return settingsResponse;
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return json(
|
||||
Object.entries(names).map(([workspace_id, display_name]) => ({
|
||||
@@ -69,13 +76,26 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
host_worker_bridge: {
|
||||
status: "ready",
|
||||
note: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
companion_console: {
|
||||
status: "ready",
|
||||
note: "fixture",
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
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") {
|
||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||
@@ -110,7 +130,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
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);
|
||||
socket.onmessage = (event) => {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
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 {
|
||||
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",
|
||||
"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: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": {
|
||||
"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]/settings/+layout.svelte`: Settings Sidebar registration.
|
||||
- `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/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
||||
- `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">
|
||||
import type {
|
||||
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 WorkspaceIdentitySettings from '$lib/workspace/settings/WorkspaceIdentitySettings.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workspace settings · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
<svelte:head><title>Workspace Identity · Settings · Yoi</title></svelte:head>
|
||||
|
||||
<section class="card settings-section" aria-labelledby="workspace-settings-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">editable</p>
|
||||
<h2 id="workspace-settings-title">Workspace Identity</h2>
|
||||
</div>
|
||||
<span class="badge success">Backend scoped</span>
|
||||
</header>
|
||||
|
||||
{#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 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 data.workspace}
|
||||
{#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`}
|
||||
<WorkspaceIdentitySettings
|
||||
workspaceId={data.workspace.workspace_id}
|
||||
workspaceName={data.workspace.display_name}
|
||||
canDelete={data.workspace.permissions.delete_workspace}
|
||||
/>
|
||||
{/key}
|
||||
{/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 () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
const source = (await Promise.all([
|
||||
"../src/routes/w/[workspaceId]/settings/+page.svelte",
|
||||
"../src/lib/workspace/settings/WorkspaceIdentitySettings.svelte",
|
||||
].map((path) => Deno.readTextFile(new URL(path, import.meta.url))))).join(
|
||||
"\n",
|
||||
);
|
||||
for (
|
||||
const token of [
|
||||
|
||||
Reference in New Issue
Block a user