diff --git a/.changeset/tidy-dashboard-forms.md b/.changeset/tidy-dashboard-forms.md
new file mode 100644
index 000000000..77225b379
--- /dev/null
+++ b/.changeset/tidy-dashboard-forms.md
@@ -0,0 +1,7 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+"@internal/docs": minor
+---
+
+Make dashboard information easier to scan with consistent labels, readable values, and wrapping for long paths and addresses. Keep conditional fields beneath their selected radio option, disable Save for unchanged drafts, and clarify warnings and validation. Apply the shared fact styling to the node dashboard as well. On the docs site, the SSH destination-confirmation guide and its screenshot now match the always-visible fingerprint.
diff --git a/apps/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx
index 69654fe2b..2a58c6e53 100644
--- a/apps/docs/content/docs/guides/ssh.mdx
+++ b/apps/docs/content/docs/guides/ssh.mdx
@@ -80,11 +80,11 @@ Select **Start SSH subshell**. If you're using another node's keys and neither t
A host key is the destination machine's public SSH key. It identifies that machine; your login key or password identifies you. Remembering the host key lets SSH detect a different machine answering at the same address later. You don't need to generate, paste, or manage host keys in the dashboard.
-
- A first connection using another node's keys asks before remembering an unfamiliar destination. This screenshot uses a disposable local SSH destination. Select the image to enlarge it.
+
+ A first connection using another node's keys asks before remembering an unfamiliar destination. This screenshot was captured from a sandboxed review instance with test data. Select the image to enlarge it.
-Check the address. If you need to verify the machine, expand **Verification details** and compare the fingerprint with one obtained from its administrator through a trusted channel. Choose **Trust and connect** to remember that identity and retry the connection, or **Cancel** to leave it untrusted.
+Check the address. The dialog shows the destination's host fingerprint and warns that the fingerprint came from the destination itself, not an independent check. If you need to verify the machine, compare the fingerprint with one obtained from its administrator through a trusted channel. Choose **Trust and connect** to remember that identity and retry the connection, or **Cancel** to leave it untrusted.
> [!warning] First-connection trust
> A key retrieved from the destination isn't independent proof that you've reached the right machine. Accepting it without checking the fingerprint trusts the first response. Subshell blocks later connections if the identity changes; it doesn't silently replace the saved identity. If that happens, stop and verify the change with the destination's administrator.
diff --git a/apps/docs/public/screenshots/ssh-destination-confirmation.png b/apps/docs/public/screenshots/ssh-destination-confirmation.png
index 84980f9ac..3a251f02d 100644
Binary files a/apps/docs/public/screenshots/ssh-destination-confirmation.png and b/apps/docs/public/screenshots/ssh-destination-confirmation.png differ
diff --git a/apps/server/web/AGENTS.md b/apps/server/web/AGENTS.md
index 907aa219e..db07bd885 100644
--- a/apps/server/web/AGENTS.md
+++ b/apps/server/web/AGENTS.md
@@ -440,8 +440,8 @@ Changed identities never use that prompt. Settings → Presets has Agent and SSH
tabs; the SSH tab manages saved connection choices, not host keys. Applying a
preset clears any local key selection. Saving a local-key preset retains only
the authentication choice, never credentials or approval. Recent destinations
-stay in the launch suggestions; `/settings/ssh` redirects to `/presets?type=ssh`. Verification details can reveal the public fingerprint without
-requiring a user to paste or manage key material.
+stay in the launch suggestions; `/settings/ssh` redirects to `/presets?type=ssh`. The confirmation shows the destination's public fingerprint outright
+(copyable), with no key material for the user to paste or manage.
Old `/connect` links open the same
subshell dialog through `/new?kind=ssh`. Failed reads must stay distinct from
empty lists, with retry actions. Read `docs/launch-form-picker.md` for the
diff --git a/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx b/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx
index 6012f0564..113628f26 100644
--- a/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx
@@ -656,9 +656,9 @@ describe("AddNodeDialog", () => {
// dropdown's items are in the DOM too — Base UI force-mounts them once the
// trigger has focus, which is where the dialog's initial focus lands (the
// old count of 2 rode the two-step never focusing the trigger — timing, not
- // truth). The paste target is the CODE row, and
+ // truth). The paste target is the fact value, and
// there is exactly one. The key shows ONCE.
- expect(screen.getAllByText("https://plane.example").filter((el) => el.tagName === "CODE")).toHaveLength(1);
+ expect(screen.getAllByText("https://plane.example").filter((el) => el.closest("dd") !== null)).toHaveLength(1);
expect(screen.getAllByText(/nsk_secret/)).toHaveLength(1);
expect(screen.getByRole("button", { name: "Copy setup key" })).toBeDefined();
expect(screen.getByRole("button", { name: "Copy server address" })).toBeDefined();
@@ -716,11 +716,11 @@ describe("AddNodeDialog", () => {
expect(label.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(trigger.compareDocumentPosition(switchButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- // The pick is the same value the app path's paste row carries — one CODE row
+ // The pick is the same value the app path's paste row carries — one fact value
// (the closed dropdown holds further copies in the DOM; see the count comment
// in the test above).
fireEvent.click(screen.getByRole("button", { name: "Desktop App" }));
- expect(screen.getAllByText("https://plane.example").filter((el) => el.tagName === "CODE")).toHaveLength(1);
+ expect(screen.getAllByText("https://plane.example").filter((el) => el.closest("dd") !== null)).toHaveLength(1);
} finally {
restore();
}
diff --git a/apps/server/web/src/components/__tests__/backup-settings.test.tsx b/apps/server/web/src/components/__tests__/backup-settings.test.tsx
index 0d953b88a..f02443fed 100644
--- a/apps/server/web/src/components/__tests__/backup-settings.test.tsx
+++ b/apps/server/web/src/components/__tests__/backup-settings.test.tsx
@@ -159,7 +159,7 @@ describe("Settings backup workflows", () => {
expect(activity.at(-1)).toBe(false);
});
- it("gates encryption on shared validation and keeps gold errors beside blurred fields", async () => {
+ it("gates encryption and distinguishes invalid values from missing input", async () => {
const requests = mockApi();
mount();
fireEvent.click(screen.getByRole("switch", { name: "Encrypt the archive with a password" }));
@@ -167,10 +167,18 @@ describe("Settings backup workflows", () => {
expect(submit.matches(":disabled, [aria-disabled=true]")).toBe(true);
const password = screen.getByLabelText("Archive password");
const confirm = screen.getByLabelText("Confirm archive password");
- for (const input of [password, confirm]) fireEvent.change(input, { target: { value: "short" } });
+ // The gold half of the tone rule: blur the EMPTY box and the sentence is
+ // "nothing typed yet" (text-warning), not "what you typed is wrong".
+ fireEvent.focus(password);
fireEvent.blur(password);
expect(screen.getByRole("alert").textContent).toContain("at least 8");
expect(screen.getByRole("alert").className).toContain("text-warning");
+ expect(screen.getByRole("alert").className).not.toContain("text-destructive");
+ // The red half: a value that validation refuses.
+ for (const input of [password, confirm]) fireEvent.change(input, { target: { value: "short" } });
+ fireEvent.blur(password);
+ expect(screen.getByRole("alert").textContent).toContain("at least 8");
+ expect(screen.getByRole("alert").className).toContain("text-destructive");
fireEvent.focus(password);
fireEvent.change(password, { target: { value: "unique archive password" } });
fireEvent.change(confirm, { target: { value: "different" } });
@@ -399,6 +407,25 @@ describe("Settings backup workflows", () => {
});
});
+ it("keeps the restore overrides below the mode group, not inside the selected option", async () => {
+ // The seven override fields apply to BOTH modes identically. They once
+ // rendered inside whichever mode was selected, so flipping the radio
+ // teleported the block (and remounted every input in it). They are a
+ // named section below the group now, and switching modes must not move
+ // the person's typing.
+ mockApi();
+ mount();
+ await configureFile();
+ expect(screen.getByRole("region", { name: "Restore overrides" })).toBeTruthy();
+ const port = screen.getByLabelText("Port (optional)");
+ fireEvent.change(port, { target: { value: "4000" } });
+ fireEvent.click(screen.getByRole("radio", { name: "This machine is different from the original" }));
+ expect(screen.getByLabelText("Port (optional)")).toBe(port);
+ expect((port as HTMLInputElement).value).toBe("4000");
+ fireEvent.click(screen.getByRole("radio", { name: "Same-machine recovery" }));
+ expect((port as HTMLInputElement).value).toBe("4000");
+ });
+
it("revalidates expired extraction before preparation and stops for review if the source changed", async () => {
let inspected = 0;
const requests = mockApi((request) => {
diff --git a/apps/server/web/src/components/__tests__/device-name-card.test.tsx b/apps/server/web/src/components/__tests__/device-name-card.test.tsx
index 2d6efd008..e4369476f 100644
--- a/apps/server/web/src/components/__tests__/device-name-card.test.tsx
+++ b/apps/server/web/src/components/__tests__/device-name-card.test.tsx
@@ -47,4 +47,20 @@ describe("DeviceNameCard", () => {
render();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Laptop");
});
+
+ it("disables Save when the field still holds the stored name", () => {
+ // The unchanged-draft rule: the button is dead at rest, lights for a real
+ // edit, and goes dead again when the edit is put back — a keypress is not
+ // a change. The stored-name path also proves the compare is against what
+ // was saved, not against a blank.
+ window.localStorage.setItem(DEVICE_NAME_KEY, "Laptop");
+ render();
+ const save = () => screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(save().getAttribute("aria-description")).toBe("No device name changes to save.");
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Kitchen display" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(false);
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Laptop" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ });
});
diff --git a/apps/server/web/src/components/__tests__/instance-name-card.test.tsx b/apps/server/web/src/components/__tests__/instance-name-card.test.tsx
index ca1637d19..788f43a78 100644
--- a/apps/server/web/src/components/__tests__/instance-name-card.test.tsx
+++ b/apps/server/web/src/components/__tests__/instance-name-card.test.tsx
@@ -82,6 +82,22 @@ describe("InstanceNameCard", () => {
await waitFor(() => expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("theo-desktop"));
});
+ it("disables Save at rest and on a revert, opens it on a real change", async () => {
+ // The unchanged-draft rule, on the card that seeds from the server: the
+ // field opens showing the stored name, so Save starts dead; a genuine
+ // edit lights it; retyping the stored name kills it again.
+ stubFetch((s) => s);
+ renderCard("Prod plane");
+ await screen.findByLabelText("Name");
+ const save = () => screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(save().getAttribute("aria-description")).toBe("No instance name changes to save.");
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Homelab" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(false);
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Prod plane" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ });
+
it("reports a failed save instead of implying it worked", async () => {
const original = globalThis.fetch;
restore.push(() => {
diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx
index 92844f32a..ba0dc176a 100644
--- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx
+++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx
@@ -1163,29 +1163,24 @@ describe("NetworkPluginCard: the state matrix", () => {
});
it("labels each address above its value, in the QUIET data-label grammar", async () => {
- // Two defects, in order, both from the same operator's live card. First the
- // tag-after-URL row (`http://… MagicDNS name`) read as two disjoint
- // things, because a small muted word after a big bold URL names nothing
- // until you scan back — so the label moved ABOVE the value. Then the
- // grammar it moved into was the wrong one: the form label's `font-strong
- // text-label` put a bold "MagicDNS name" over its URL while the `Fact`
- // rows above it set a quiet "Client version" over theirs, and a card
- // holding both grammars reads as two systems. These are read-only facts, so
- // the label carries `Fact`'s `dt` classes — quiet colour, no weight token,
- // and the body size from the LIST, exactly as the `
` supplies it.
+ // Facts use smaller muted labels and full-size values across cards and dialogs.
await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } }));
- const items = screen.getAllByRole("listitem");
+ const items = ADDRESSES.map((address) => screen.getByText(address.label).closest("div") as HTMLElement);
const first = items[0] as HTMLElement;
expect(first.children[0]?.textContent).toBe("MagicDNS name");
expect(first.children[0]?.className).toContain("text-muted-foreground");
expect(first.children[0]?.className).not.toContain("font-strong");
expect(first.children[0]?.className).not.toContain("text-label");
- // The size comes from the list so it cannot drift from the `
` beside it.
- expect(first.parentElement?.className).toContain("text-sm");
+ expect(first.children[0]?.className).toContain("text-detail");
+ expect(first.children[1]?.className).toContain("text-body");
expect(first.children[1]?.textContent).toContain("https://box.tail1234.ts.net");
- // The secure-context sentence keeps its own line below the value, and it
- // is still the address's own — one per address, comparative by repetition.
- expect(first.children[2]?.textContent).toBe("Passkeys and secure cookies work at this address.");
+ // The secure-context sentence keeps its OWN element below the value (a
+ // merged line would pass a plain textContent check), and it is still the
+ // address's own — one per address, comparative by repetition.
+ const sentence = first.children[1]?.querySelector("p");
+ expect(sentence?.textContent).toBe("Passkeys and secure cookies work at this address.");
+ // The value and its copy button come first; the sentence follows them.
+ expect(first.children[1]?.firstElementChild).not.toBe(sentence);
const second = items[1] as HTMLElement;
expect(second.children[0]?.textContent).toBe("Tailscale IP");
});
@@ -1196,7 +1191,8 @@ describe("NetworkPluginCard: the state matrix", () => {
// here by scheme, by secure context or by label would make the page
// contradict its own data for a reason no reader can see.
await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } }));
- const shown = screen.getAllByRole("listitem").map((item) => item.textContent ?? "");
+ const list = screen.getByText(ADDRESSES[0]?.label ?? "").closest("dl");
+ const shown = Array.from(list?.children ?? [], (item) => item.textContent ?? "");
expect(shown[0]).toContain("https://box.tail1234.ts.net");
expect(shown[1]).toContain("http://100.64.0.1:3080");
});
diff --git a/apps/server/web/src/components/__tests__/profile-card.test.tsx b/apps/server/web/src/components/__tests__/profile-card.test.tsx
index 77e937223..cd2db1b5a 100644
--- a/apps/server/web/src/components/__tests__/profile-card.test.tsx
+++ b/apps/server/web/src/components/__tests__/profile-card.test.tsx
@@ -71,6 +71,28 @@ describe("ProfileCard", () => {
expect(await screen.findByText("saved")).toBeDefined();
});
+ it("disables Save at rest, opens it on a real edit, and re-deadens it on a revert", async () => {
+ // The unchanged-draft rule this PR added: Save is dead while the field
+ // shows the saved name, and a type-then-retype-the-same-name is no change
+ // either — the draft is compared to the stored value, not to "was a key
+ // pressed". The reason rides the disabled explanation, as on every card.
+ let pressed = 0;
+ renderCard(async () => {
+ pressed++;
+ return { error: null };
+ });
+ const input = (await screen.findByLabelText("Name")) as HTMLInputElement;
+ const save = () => screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(save().getAttribute("aria-description")).toBe("No profile changes to save.");
+ fireEvent.change(input, { target: { value: "Thea" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(false);
+ fireEvent.change(input, { target: { value: "Old Name" } }); // back to stored
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(save().getAttribute("aria-description")).toBe("No profile changes to save.");
+ expect(pressed).toBe(0);
+ });
+
it("keeps the draft and reports the error when the save fails", async () => {
renderCard(async () => ({ error: { message: "Server said no" } }));
const input = (await screen.findByLabelText("Name")) as HTMLInputElement;
diff --git a/apps/server/web/src/components/__tests__/restart-dialog.test.tsx b/apps/server/web/src/components/__tests__/restart-dialog.test.tsx
index ef688e9fd..7971a85f4 100644
--- a/apps/server/web/src/components/__tests__/restart-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/restart-dialog.test.tsx
@@ -46,6 +46,6 @@ describe("RestartDialog pane safety", () => {
// Absence of evidence is not evidence of safety: a definition that could
// not be read gets the same refusal as one known to be lethal.
open({ installed: true, paneSafety: "unknown" });
- expect(screen.getByText(/close every running subshell/)).toBeTruthy();
+ expect(screen.getByText(/Restarting may close them/)).toBeTruthy();
});
});
diff --git a/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx b/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx
index 9977bf7cf..29d2adcbe 100644
--- a/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx
@@ -59,7 +59,40 @@ describe("SharingDialog", () => {
// The grant row renders (Everyone, one Remove control); the add-row
// picker omits Everyone since it's already shared.
expect(await screen.findByText("Everyone")).toBeDefined();
- expect(screen.getByRole("button", { name: "Remove" })).toBeDefined();
+ expect(screen.getByRole("button", { name: /^Remove access for/ })).toBeDefined();
+ } finally {
+ restore();
+ }
+ });
+
+ it("disables Save until access changes and disables it again when changes are undone", async () => {
+ const { calls, restore } = mockFetch([{ granteeUserId: null, permission: "view" }]);
+ try {
+ renderDialog();
+ await screen.findByText("Everyone");
+ const save = () => screen.getByRole("button", { name: "Save" });
+ const disabled = () => save().matches(":disabled, [aria-disabled=true]");
+ await waitFor(() => expect(disabled()).toBe(true));
+ fireEvent.click(save());
+ expect(calls.some((call) => call.method === "PUT")).toBe(false);
+ fireEvent.click(screen.getByRole("combobox", { name: "Access for Everyone" }));
+ const editOption = await screen.findByRole("option", { name: "View + edit" });
+ fireEvent.pointerDown(editOption);
+ fireEvent.pointerUp(editOption);
+ fireEvent.click(editOption);
+ await waitFor(() => expect(disabled()).toBe(false));
+ fireEvent.click(screen.getByRole("combobox", { name: "Access for Everyone" }));
+ const viewOption = await screen.findByRole("option", { name: "View" });
+ fireEvent.pointerDown(viewOption);
+ fireEvent.pointerUp(viewOption);
+ fireEvent.click(viewOption);
+ await waitFor(() => expect(disabled()).toBe(true));
+ fireEvent.click(screen.getByRole("button", { name: "Remove access for Everyone" }));
+ await waitFor(() => expect(disabled()).toBe(false));
+ // Re-adding the original grant also restores the saved state.
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+ await waitFor(() => expect(disabled()).toBe(true));
+ expect(calls.some((call) => call.method === "PUT")).toBe(false);
} finally {
restore();
}
@@ -100,7 +133,12 @@ describe("SharingDialog", () => {
try {
renderDialog();
await screen.findByText("Everyone");
- fireEvent.click(screen.getByRole("button", { name: "Remove" }));
+ fireEvent.click(screen.getByRole("button", { name: /^Remove access for/ }));
+ // No unsaved-changes sentence (design rule: Save's disabled state carries
+ // that fact); the empty state names the PENDING result instead.
+ expect(screen.queryByText(/unsaved/i)).toBeNull();
+ expect(screen.getByText("No one will have shared access after you save.")).toBeTruthy();
+ expect(calls.some((call) => call.method === "PUT")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/shares"));
diff --git a/apps/server/web/src/components/__tests__/supervision-dialog.test.tsx b/apps/server/web/src/components/__tests__/supervision-dialog.test.tsx
index ef53dac3c..69c6e26ae 100644
--- a/apps/server/web/src/components/__tests__/supervision-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/supervision-dialog.test.tsx
@@ -7,6 +7,12 @@ afterEach(cleanup);
const noop = () => {};
+/** The base view with only the service's pane-safety answer changed. */
+function withPaneSafety(paneSafety: "kills" | "unknown") {
+ const view = deploymentView();
+ return { ...view, service: { ...view.service, paneSafety } };
+}
+
describe("consequences", () => {
it("names the platform's own agent and ends on the fact people are actually asking about", () => {
const mac = consequences("app", "darwin", true);
@@ -116,6 +122,58 @@ describe("SupervisionDialog", () => {
expect(screen.getByText(/Stopped the server this app was running/)).toBeTruthy();
});
+ it("states the lethal panes fact once: the notice carries it, the list drops it", () => {
+ // The filter is by the line's own text, not its position in the list, so
+ // a reorder of `consequences` cannot silently drop a different step while
+ // the notice repeats the panes one.
+ render(
+ ,
+ );
+ expect(screen.getAllByText("Every running subshell will be closed.")).toHaveLength(1);
+ expect(screen.getByText("Quitting the app will stop the server")).toBeTruthy();
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
+ });
+
+ it("says survival is undetermined once when the definition cannot be read", () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByText("Whether running subshells survive could not be determined.")).toHaveLength(1);
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
+ });
+
+ it("keeps every step in the list when the switch is not lethal", () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByRole("listitem")).toHaveLength(4);
+ expect(screen.getByText("Running subshells keep running")).toBeTruthy();
+ });
+
it("shows the refusal where the person is", () => {
render(
{
}
});
+ it("edit mode starts unchanged, so Save is dead until something actually differs", async () => {
+ // The unchanged-draft rule on the surface whose save WRITES provider config:
+ // the field opens seeded to the stored row, so Save starts dead and says
+ // why; a real edit lights it; putting the edit back kills it again. The
+ // compare runs over the wire shape (trimmed name/issuer/clientId, the
+ // order-insensitive domain list), not over "was a key pressed".
+ const { restore } = mockFetch({ appBaseUrl: "https://plane.example" });
+ try {
+ renderDialog({ provider: view({ allowedDomains: ["example.test"] }) });
+ await settle();
+ const save = () => screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(save().getAttribute("aria-description")).toBe("No provider changes to save.");
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Workspace Google" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(false);
+ // Retyping the domain it already had (the list sorts, order is noise).
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Google" } });
+ fireEvent.change(screen.getByLabelText(/Allowed e-mail domains/), { target: { value: "example.test" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ } finally {
+ restore();
+ }
+ });
+
it("clicking out or pressing Escape keeps the form; a half-typed issuer survives", async () => {
// The loss this pins: an outside press discarded the whole dialog with a
// half-entered issuer in it (the 2026-09-30 form-dialog ruling, now read
diff --git a/apps/server/web/src/components/auth/provider-dialog.tsx b/apps/server/web/src/components/auth/provider-dialog.tsx
index 964ab758a..4a14c5839 100644
--- a/apps/server/web/src/components/auth/provider-dialog.tsx
+++ b/apps/server/web/src/components/auth/provider-dialog.tsx
@@ -106,9 +106,22 @@ function ProviderForm({ provider, onDone }: { provider: ProviderAdminView | null
// sends this exact slug as `id`, so the preview and the truth are one
// string (§5a's "live while filling").
const displayId = editing ? provider.id : slug;
+ const unchanged =
+ provider !== null &&
+ name.trim() === provider.name &&
+ issuer.trim() === (provider.issuer ?? "") &&
+ clientId.trim() === (provider.clientId ?? "") &&
+ secret === "" &&
+ overrideOrigin === (provider.callbackBaseUrl ?? null) &&
+ domains
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .sort()
+ .join(",") === [...(provider.allowedDomains ?? [])].sort().join(",");
async function save(): Promise {
setSaveError(null);
- if (invalidOverride) return;
+ if (invalidOverride || unchanged || busy) return;
const shared = {
name: name.trim(),
issuer: issuer.trim(),
@@ -161,6 +174,7 @@ function ProviderForm({ provider, onDone }: { provider: ProviderAdminView | null
: null;
const saveDisabled =
+ unchanged ||
busy ||
name.trim() === "" ||
issuer.trim() === "" ||
@@ -408,11 +422,13 @@ function ProviderForm({ provider, onDone }: { provider: ProviderAdminView | null
disabledReason={
busy
? "Wait for the sign-in provider to save."
- : invalidOverride
- ? "Fix the provider endpoint addresses before saving."
- : entries.length === 0
- ? "Add an entry point first."
- : "Complete the required provider fields first."
+ : unchanged
+ ? "No provider changes to save."
+ : invalidOverride
+ ? "Fix the provider endpoint addresses before saving."
+ : entries.length === 0
+ ? "Add an entry point first."
+ : "Complete the required provider fields first."
}
>
{busy ? "Saving…" : editing ? "Save" : "Add provider"}
diff --git a/apps/server/web/src/components/auth/providers-table.tsx b/apps/server/web/src/components/auth/providers-table.tsx
index bd5ebb8ba..7c204ad49 100644
--- a/apps/server/web/src/components/auth/providers-table.tsx
+++ b/apps/server/web/src/components/auth/providers-table.tsx
@@ -3,6 +3,7 @@ import { Pencil, Settings2, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { type ActionItem, ActionsMenu } from "@/components/actions-menu";
import { ProviderSettingsDialog } from "@/components/auth/provider-settings-dialog";
+import { FactList } from "@/components/fact-list";
import { useDeleteAuthProvider, usePatchAuthProvider } from "@/hooks/use-auth-providers";
import {
EMAIL_PROVIDER_ID,
@@ -127,16 +128,14 @@ function ProviderRow({
can repeat, so the two strings that actually identify the row —
the slug id and the issuer — get label-over-value rows, the
values in the mono foreground voice they wear everywhere else. */}
-
-
-
Slug id
-
{provider.id}
-
-
-
Issuer
-
{provider.issuer ?? ""}
-
-
+
+
+
>
),
confirmLabel: "Remove",
diff --git a/apps/server/web/src/components/backups/backup-workflow.tsx b/apps/server/web/src/components/backups/backup-workflow.tsx
index 4cabf64d8..840978279 100644
--- a/apps/server/web/src/components/backups/backup-workflow.tsx
+++ b/apps/server/web/src/components/backups/backup-workflow.tsx
@@ -3,13 +3,13 @@ import {
CardFooter,
CardHeader,
CardTitle,
- cn,
Card as WorkflowCard,
CardContent as WorkflowContent,
} from "@internal/node-admin";
import { CheckCircle2, LoaderCircle } from "lucide-react";
-import { Fragment, type ReactNode } from "react";
-import { CopyButton } from "@/components/copy-button";
+import type { ReactNode } from "react";
+import { FactList } from "@/components/fact-list";
+import { ErrorNotice } from "@/components/ui/notice";
import { Card, CardContent } from "./backup-card";
export function BackupWorkflow({
@@ -69,34 +69,16 @@ export function BackupProgress({
export function BackupFacts({ rows }: { rows: { label: string; value: string; copy?: boolean }[] }) {
return (
-
)}
diff --git a/apps/server/web/src/components/backups/field-problem-tone.ts b/apps/server/web/src/components/backups/field-problem-tone.ts
new file mode 100644
index 000000000..fe54a7020
--- /dev/null
+++ b/apps/server/web/src/components/backups/field-problem-tone.ts
@@ -0,0 +1,16 @@
+import { cn } from "@internal/node-admin";
+
+/**
+ * The tone of a backup field's validation sentence, decided the same way on
+ * every backup card (ruling 2026-09-30): gold means "nothing typed yet", red
+ * means "what you typed is wrong".
+ *
+ * The two cards once inferred the tone separately, one from the raw value and
+ * one from its trimmed form, so a whitespace-only field was red on Create and
+ * gold on Restore. A whitespace-only input is nothing typed, everywhere.
+ * A problem supplied from outside the form (a server refusal, a failed
+ * decrypt) is always the hard kind: something the person wrote was wrong.
+ */
+export function problemTone(value: string, suppliedProblem?: string | null): string {
+ return cn("text-detail", suppliedProblem || value.trim() !== "" ? "text-destructive" : "text-warning");
+}
diff --git a/apps/server/web/src/components/backups/restore-backup-card.tsx b/apps/server/web/src/components/backups/restore-backup-card.tsx
index 1c2d347fb..f56322f42 100644
--- a/apps/server/web/src/components/backups/restore-backup-card.tsx
+++ b/apps/server/web/src/components/backups/restore-backup-card.tsx
@@ -4,6 +4,7 @@ import { useStore } from "@tanstack/react-form";
import { LoaderCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
+import { ChoiceFields } from "@/components/ui/choice-fields";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -18,6 +19,7 @@ import { type FieldProblems, fieldError, makeForm, useSubmitDisabled, useSubmitD
import { setRestoreProgressActive } from "@/lib/restore-progress";
import { Card, CardContent, CardHeader, CardTitle } from "./backup-card";
import { BackupError, BackupFacts, BackupProgress, BackupWorkflow } from "./backup-workflow";
+import { problemTone } from "./field-problem-tone";
type InspectionDraft = { file: File | null; path: string; password: string; encrypted: boolean };
function inspectionProblems(draft: InspectionDraft): FieldProblems {
@@ -421,7 +423,7 @@ export function RestoreBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
onChange={(event) => field.handleChange(event.target.value)}
/>
{problem && (
-
+ {/* The seven overrides apply to BOTH modes (the request sends
+ them identically however the mode is set), so they are a
+ named section below the group rather than a panel under
+ whichever option happens to be selected — flipping the
+ radio would otherwise teleport the whole block. */}
+ {!inspection?.legacyDatabaseOnly && (
+
+
+
+ Restore overrides
+
+
+ Leave a field empty to restore to the location or setting recorded in the backup.
+
This backup contains only the database. It restores into this server using its current
@@ -716,17 +754,6 @@ export function RestoreBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
)}
>
diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
index c35b51307..ecb63be29 100644
--- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
+++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
@@ -92,7 +92,7 @@ it("saves the local-key radio choice without requiring a file or approval", asyn
expect(screen.getByRole("radio", { name: "Use a key from my machine" }).getAttribute("aria-checked")).toBe("true");
const localKeyChoice = screen.getByRole("radio", { name: "Use a key from my machine" });
expect(document.getElementById(localKeyChoice.getAttribute("aria-describedby") ?? "")?.textContent).toContain(
- "Choose a key from your machine when creating a new SSH subshell",
+ "Choose a private key on the device you’re using now.",
);
expect(screen.queryByRole("button", { name: "Choose key…" })).toBeNull();
expect(screen.queryByRole("checkbox", { name: "I trust this website and its operator" })).toBeNull();
diff --git a/apps/server/web/src/components/presets/command-paste-field.tsx b/apps/server/web/src/components/presets/command-paste-field.tsx
index 619aa3e0f..05bb7829a 100644
--- a/apps/server/web/src/components/presets/command-paste-field.tsx
+++ b/apps/server/web/src/components/presets/command-paste-field.tsx
@@ -1,4 +1,4 @@
-import { errMessage } from "@internal/node-admin";
+import { errMessage, Fact } from "@internal/node-admin";
import { useMemo, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { parseCommandPaste, presetFormToCommand } from "@/lib/preset-command";
@@ -116,25 +116,23 @@ export function CommandPasteField({
: `${envCount} env var${envCount === 1 ? "" : "s"} · ${flagCount} flag${flagCount === 1 ? "" : "s"}`}
{command.env.length > 0 && (
-
+
{command.env.map((row) => (
-
-
{row.key}
-
{row.value || "(empty)"}
-
+
+ {row.value || "(empty)"}
+
))}
)}
{command.flags.length > 0 && (
-
+
{command.flags.map((row, i) => (
// Flags are positional and may legitimately repeat
// (`--allow a --allow b`), so the index is the only key.
// biome-ignore lint/suspicious/noArrayIndexKey: see above
-
-
{row.flag}
-
{row.value}
-
+
+ {row.value || "(empty)"}
+
))}
)}
diff --git a/apps/server/web/src/components/profile-card.tsx b/apps/server/web/src/components/profile-card.tsx
index 00df53583..99700acb0 100644
--- a/apps/server/web/src/components/profile-card.tsx
+++ b/apps/server/web/src/components/profile-card.tsx
@@ -1,6 +1,7 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@internal/node-admin";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
+import { FactList } from "@/components/fact-list";
import { useCurrentUser } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { CURRENT_USER_QUERY_KEY } from "@/lib/query-keys";
@@ -55,9 +56,11 @@ export function ProfileCard({ updateUser = (input) => authClient.updateUser(inpu
const shown = nameDraft ?? user?.name ?? "";
+ const unchanged = nameIsUsable(shown) === user?.name;
+
async function save() {
const name = nameIsUsable(shown);
- if (name === null) return;
+ if (name === null || unchanged || busy) return;
setBusy(true);
setError(null);
setSaved(false);
@@ -111,19 +114,20 @@ export function ProfileCard({ updateUser = (input) => authClient.updateUser(inpu
void save()}
- disabled={busy || nameIsUsable(shown) === null}
- disabledReason={busy ? "Wait for your profile to save." : "Enter a display name first."}
+ disabled={busy || nameIsUsable(shown) === null || unchanged}
+ disabledReason={
+ busy
+ ? "Wait for your profile to save."
+ : unchanged
+ ? "No profile changes to save."
+ : "Enter a display name first."
+ }
>
{busy ? "Saving…" : "Save"}
{saved && saved}
-
-
- {/* Read-only by design: the email IS the credential; changing it
- is not offered here (muted to say so at a glance). */}
-
-
+
{error &&
{error}
}
>
)}
diff --git a/apps/server/web/src/components/prompts/__tests__/prompt-form-dialog.test.tsx b/apps/server/web/src/components/prompts/__tests__/prompt-form-dialog.test.tsx
index 7f8450ec4..e3046e450 100644
--- a/apps/server/web/src/components/prompts/__tests__/prompt-form-dialog.test.tsx
+++ b/apps/server/web/src/components/prompts/__tests__/prompt-form-dialog.test.tsx
@@ -24,11 +24,24 @@ function mockFetch() {
return { posts, restore: () => (globalThis.fetch = realFetch) };
}
-function renderDialog(props: { onSaved?: () => void; onClose?: () => void } = {}) {
+function renderDialog(
+ props: {
+ onSaved?: () => void;
+ onClose?: () => void;
+ editingId?: string;
+ initial?: { description: string; body: string; shared: boolean };
+ } = {},
+) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
- props.onClose?.()} onSaved={props.onSaved} />
+ props.onClose?.()}
+ onSaved={props.onSaved}
+ editingId={props.editingId}
+ initial={props.initial}
+ />
,
);
}
@@ -89,3 +102,77 @@ describe("PromptFormDialog gating", () => {
await waitFor(() => expect(saved).toBe(true));
});
});
+
+describe("PromptFormDialog edit mode", () => {
+ let restore: (() => void) | undefined;
+ afterEach(() => {
+ restore?.();
+ restore = undefined;
+ });
+
+ it("starts dead, opens on a real edit, and re-deadens when the edit is reverted", () => {
+ // The unchanged-draft rule on the prompt editor. Create mode cannot see
+ // this path at all (its fields start empty), so edit mode is pinned here:
+ // a dialog that opens already valid used to offer Save for a draft that
+ // differed from the stored row by nothing.
+ const realFetch = globalThis.fetch;
+ const puts: Record[] = [];
+ globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
+ const url = String(input);
+ if (url === "/api/prompts/pr1" && (init?.method ?? "GET") === "PUT") {
+ puts.push(JSON.parse(String(init?.body)) as Record);
+ return Promise.resolve(new Response(JSON.stringify({ ok: true })));
+ }
+ return realFetch(input as never, init as never);
+ }) as typeof fetch;
+ restore = () => {
+ globalThis.fetch = realFetch;
+ };
+ renderDialog({ editingId: "pr1", initial: { description: "Kickoff", body: "start the task", shared: false } });
+ const save = () => screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(save().getAttribute("aria-description")).toBe("No changes to save.");
+ fireEvent.change(descriptionField(), { target: { value: "Kickoff v2" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(false);
+ fireEvent.change(descriptionField(), { target: { value: "Kickoff" } });
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(true);
+ expect(puts).toHaveLength(0);
+ });
+
+ it("sends the edited draft as a PUT and reports done", async () => {
+ // The other half of the edit-mode loop: a REAL change lights Save, and the
+ // press rides the wire once with the full replacement draft (PUT is a
+ // complete replacement of the fields, so the body must carry all three).
+ const realFetch = globalThis.fetch;
+ const puts: { url: string; body: Record }[] = [];
+ globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
+ const url = String(input);
+ if (url === "/api/prompts/pr1" && (init?.method ?? "GET") === "PUT") {
+ puts.push({ url, body: JSON.parse(String(init?.body)) as Record });
+ return Promise.resolve(new Response(JSON.stringify({ ok: true })));
+ }
+ return realFetch(input as never, init as never);
+ }) as typeof fetch;
+ let saved = false;
+ try {
+ renderDialog({
+ editingId: "pr1",
+ initial: { description: "Kickoff", body: "start the task", shared: false },
+ onSaved: () => {
+ saved = true;
+ },
+ });
+ fireEvent.change(bodyField(), { target: { value: "start the task now" } });
+ const save = () => screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save().matches(":disabled, [aria-disabled=true]")).toBe(false);
+ fireEvent.click(save());
+ await waitFor(() => {
+ expect(puts).toHaveLength(1);
+ expect(puts[0]?.body).toEqual({ description: "Kickoff", body: "start the task now", shared: false });
+ expect(saved).toBe(true);
+ });
+ } finally {
+ globalThis.fetch = realFetch;
+ }
+ });
+});
diff --git a/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx b/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx
index 5e63d3ef1..5f6dfcf02 100644
--- a/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx
+++ b/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx
@@ -132,11 +132,13 @@ describe("StackFormDialog", () => {
shared: false,
},
});
+ expect(saveButton(true).matches(":disabled, [aria-disabled=true]")).toBe(true);
+ fireEvent.change(screen.getByLabelText(/Label/), { target: { value: "Changed label" } });
fireEvent.click(saveButton(true));
await waitFor(() => expect(m.calls.length).toBe(1));
// The unlabeled inline row rides UNLABELED (the placeholder is display).
expect(m.calls[0]?.body).toEqual({
- label: "Mixed",
+ label: "Changed label",
items: [{ promptId: "p1" }, { body: "mine" }],
shared: false,
});
@@ -149,6 +151,8 @@ describe("StackFormDialog", () => {
const m = mockFetch({ failWith: "The stack's text joined is 30000 characters" });
try {
renderDialog({ editingId: "st1", initial: { label: "Big", blocks: [block({ body: "x" })], shared: false } });
+ expect(saveButton(true).matches(":disabled, [aria-disabled=true]")).toBe(true);
+ fireEvent.change(screen.getByLabelText(/Label/), { target: { value: "Changed label" } });
fireEvent.click(saveButton(true));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("30000 characters");
diff --git a/apps/server/web/src/components/prompts/prompt-form-dialog.tsx b/apps/server/web/src/components/prompts/prompt-form-dialog.tsx
index 1ce6a083e..8d6223a23 100644
--- a/apps/server/web/src/components/prompts/prompt-form-dialog.tsx
+++ b/apps/server/web/src/components/prompts/prompt-form-dialog.tsx
@@ -1,4 +1,5 @@
import { Button, errMessage, Input, Label, Switch } from "@internal/node-admin";
+import { useStore } from "@tanstack/react-form";
import { useState } from "react";
import {
Dialog,
@@ -45,10 +46,17 @@ export function PromptFormDialog({
const update = useUpdatePrompt();
const busy = create.isPending || update.isPending;
+ const unchangedDraft = (values: PromptDraft) =>
+ !!editingId &&
+ !!initial &&
+ values.description.trim() === initial.description.trim() &&
+ values.body === initial.body &&
+ values.shared === initial.shared;
const form = makeForm({
defaultValues: initial ?? { description: "", body: "", shared: false },
validator: promptDraftSchema,
onSubmit: async (draft) => {
+ if (unchangedDraft(draft)) return;
// The guard behind the gate (Enter-key paths, races).
if (draft.description.trim() === "" || draft.body.trim() === "") return;
setServerError(null);
@@ -65,8 +73,10 @@ export function PromptFormDialog({
}
},
});
- const disabled = useSubmitDisabled(form, busy);
- const disabledReason = useSubmitDisabledReason(form, busy);
+ const unchanged = useStore(form.store, (state) => unchangedDraft(state.values));
+ const disabled = useSubmitDisabled(form, busy) || unchanged;
+ const validationReason = useSubmitDisabledReason(form, busy);
+ const disabledReason = validationReason ?? (unchanged ? "No changes to save." : undefined);
return (