Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tidy-dashboard-forms.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions apps/docs/content/docs/guides/ssh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<figure className="docs-screenshot">
<ImageZoom src="/screenshots/ssh-destination-confirmation.png" alt="Connect to this destination confirmation with the destination address, an explanation of remembering its identity, Verification details, Cancel, and Trust and connect." width="816" height="676" loading="lazy" decoding="async" unoptimized />
<figcaption>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.</figcaption>
<ImageZoom src="/screenshots/ssh-destination-confirmation.png" alt="Connect to this destination confirmation showing the destination address, the host fingerprint with a copy button, an explanation of remembering its identity, a warning that the identity isn't independently verified, and Cancel and Trust and connect buttons." width="840" height="1032" loading="lazy" decoding="async" unoptimized />
<figcaption>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.</figcaption>
</figure>

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.
Expand Down
Binary file modified apps/docs/public/screenshots/ssh-destination-confirmation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions apps/server/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down
31 changes: 29 additions & 2 deletions apps/server/web/src/components/__tests__/backup-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,26 @@ 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(<CreateBackupCard />);
fireEvent.click(screen.getByRole("switch", { name: "Encrypt the archive with a password" }));
const submit = screen.getByRole("button", { name: "Create backup" }) as HTMLButtonElement;
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" } });
Expand Down Expand Up @@ -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(<RestoreBackupCard />);
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) => {
Expand Down
16 changes: 16 additions & 0 deletions apps/server/web/src/components/__tests__/device-name-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,20 @@ describe("DeviceNameCard", () => {
render(<DeviceNameCard />);
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(<DeviceNameCard />);
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dl>` 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 `<dl>` 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");
});
Expand All @@ -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");
});
Expand Down
22 changes: 22 additions & 0 deletions apps/server/web/src/components/__tests__/profile-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading