feat(setup): center onboarding and add secure token help

This commit is contained in:
Magent release tooling
2026-09-19 18:22:25 +12:00
parent 5fa5d45535
commit 4aba89c063
8 changed files with 218 additions and 28 deletions
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useRef, useState } from "react";
import { copySetupTokenCommand, SETUP_TOKEN_COMMAND } from "./setup-token-help";
import styles from "./setup.module.css";
export default function SetupTokenHelp({ disabled = false }: { disabled?: boolean }) {
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const commandField = useRef<HTMLTextAreaElement>(null);
const [copyStatus, setCopyStatus] = useState("");
const copyCommand = async () => {
const result = await copySetupTokenCommand(navigator.clipboard, commandField.current);
setCopyStatus(
result === "copied"
? "Command copied. Paste it into the Magent container console."
: result === "selected"
? "Automatic copying is unavailable. The command is selected; press Ctrl+C (Command+C on Mac), or touch and hold to copy."
: "Automatic copying is unavailable. Select and copy the command above.",
);
};
return (
<div className={styles.tokenHelp}>
<button
ref={trigger}
type="button"
className="ghost-button"
disabled={disabled}
aria-haspopup="dialog"
aria-controls="setup-token-help"
onClick={() => {
setCopyStatus("");
dialog.current?.showModal();
}}
>
Get setup token
</button>
<dialog
ref={dialog}
id="setup-token-help"
className={styles.tokenHelpDialog}
aria-labelledby="setup-token-help-title"
aria-describedby="setup-token-help-description"
onClose={() => trigger.current?.focus()}
>
<header className={styles.tokenHelpHeading}>
<h2 id="setup-token-help-title">Get your setup token</h2>
<button type="button" className="ghost-button" onClick={() => dialog.current?.close()}>
Close
</button>
</header>
<p id="setup-token-help-description">
Magent generates a private setup token when a managed installation starts. Retrieve it from your server
console to create the first administrator.
</p>
<ol className={styles.tokenHelpSteps}>
<li>In Portainer, open Containers and select the healthy Magent container.</li>
<li>
Open Console, choose command <code>/bin/ash</code> and user <code>magent</code>, then connect.
</li>
<li>Run the command below, then copy its output into the Setup token field on this page.</li>
</ol>
<label htmlFor="setup-token-command">Container console command</label>
<textarea
ref={commandField}
id="setup-token-command"
className={styles.tokenCommand}
readOnly
rows={3}
spellCheck={false}
value={SETUP_TOKEN_COMMAND}
/>
<button type="button" onClick={() => void copyCommand()}>
Copy command
</button>
<p role="status" aria-live="polite" className={styles.copyStatus}>
{copyStatus}
</p>
<p>
Keep the token private. Anyone with it and access to this installation can create the first administrator. The
command stops returning it once an administrator exists.
</p>
<p>
For a manual deployment, use the <code>SETUP_TOKEN</code> from your deployment environment.
</p>
</dialog>
</div>
);
}
+9 -12
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, type FormEvent } from "react";
import { apiUrl, requestJson } from "../lib/api-client";
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
import MagentMark from "../ui/MagentMark";
import SetupTokenHelp from "./SetupTokenHelp";
import { serviceStatusLabel } from "../admin/configNavigation";
import {
ALL_FIELDS,
@@ -352,7 +353,7 @@ export default function SetupPage() {
Retry
</button>
) : forbidden ? (
<section className={styles.panel}>
<section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}>
@@ -360,19 +361,12 @@ export default function SetupPage() {
</button>
</section>
) : !admin ? (
<section className={styles.panel}>
<section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
<p>
{status.needs_admin ? (
<>
Enter the SETUP_TOKEN from your deployment environment. For a managed Portainer install, open the
Magent container console and run <code>python -m app.container_bootstrap setup-token</code> to
retrieve it. Only the server operator can create the first administrator; the console command stops
returning the token after that account exists.
</>
) : (
"Use your local Magent administrator account. Settings are never available to unauthenticated visitors."
)}
{status.needs_admin
? "Use your private setup token to create the first administrator account."
: "Use your local Magent administrator account. Settings are never available to unauthenticated visitors."}
</p>
<form onSubmit={authenticate} className={styles.account}>
{status.needs_admin && (
@@ -409,8 +403,11 @@ export default function SetupPage() {
maxLength={1024}
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
aria-describedby="setup-token-hint"
disabled={!!busy}
/>
<p id="setup-token-hint">Retrieve this token from your server console.</p>
<SetupTokenHelp disabled={!!busy} />
</div>
)}
<div className={styles.field}>
@@ -0,0 +1,60 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import SetupTokenHelp from "./SetupTokenHelp";
import { copySetupTokenCommand, SETUP_TOKEN_COMMAND } from "./setup-token-help";
describe("setup token console help", () => {
it("copies only the retrieval command when clipboard access succeeds", async () => {
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(clipboard, commandField)).toBe("copied");
expect(clipboard.writeText).toHaveBeenCalledExactlyOnceWith("python -m app.container_bootstrap setup-token");
expect(commandField.select).not.toHaveBeenCalled();
});
it("selects the command for manual copying when LAN HTTP has no clipboard API", async () => {
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(undefined, commandField)).toBe("selected");
expect(commandField.focus).toHaveBeenCalledOnce();
expect(commandField.select).toHaveBeenCalledOnce();
});
it("offers manual copying instead of reporting success when clipboard permission is denied", async () => {
const clipboard = { writeText: vi.fn().mockRejectedValue(new Error("Clipboard permission denied")) };
const commandField = { focus: vi.fn(), select: vi.fn() };
expect(await copySetupTokenCommand(clipboard, commandField)).toBe("selected");
expect(commandField.focus).toHaveBeenCalledOnce();
expect(commandField.select).toHaveBeenCalledOnce();
});
it("does not claim a selection or successful copy if the command field is unavailable", async () => {
expect(await copySetupTokenCommand(undefined, null)).toBe("manual");
});
it("provides labelled native dialog controls and the managed and manual retrieval instructions", () => {
const html = renderToStaticMarkup(<SetupTokenHelp />);
expect(html).toContain('aria-haspopup="dialog"');
expect(html).toContain('aria-controls="setup-token-help"');
expect(html).toContain('<dialog id="setup-token-help"');
expect(html).toContain('aria-labelledby="setup-token-help-title"');
expect(html).toContain('aria-describedby="setup-token-help-description"');
expect(html).toContain('for="setup-token-command"');
expect(html).toMatch(/readonly=""/i);
expect(html).toContain('role="status"');
expect(html).toContain("Portainer");
expect(html).toContain("/bin/ash");
expect(html).toContain("<code>magent</code>");
expect(html).toContain(SETUP_TOKEN_COMMAND);
expect(html).toContain("<code>SETUP_TOKEN</code>");
expect(html).not.toContain('type="submit"');
expect(html).not.toContain("Command copied.");
});
it("can disable the help trigger while account creation is busy", () => {
expect(renderToStaticMarkup(<SetupTokenHelp disabled />)).toMatch(/<button[^>]*disabled=""[^>]*aria-haspopup/);
});
});
+24
View File
@@ -0,0 +1,24 @@
export const SETUP_TOKEN_COMMAND = "python -m app.container_bootstrap setup-token";
type ClipboardWriter = Pick<Clipboard, "writeText">;
type CommandField = Pick<HTMLTextAreaElement, "focus" | "select">;
export async function copySetupTokenCommand(
clipboard: ClipboardWriter | undefined,
commandField: CommandField | null,
): Promise<"copied" | "selected" | "manual"> {
try {
if (clipboard?.writeText) {
await clipboard.writeText(SETUP_TOKEN_COMMAND);
return "copied";
}
} catch {
// Clipboard access may be unavailable on LAN HTTP or denied by the browser.
}
if (commandField) {
commandField.focus();
commandField.select();
return "selected";
}
return "manual";
}
+16 -3
View File
@@ -1,10 +1,11 @@
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
.heading { margin-bottom: 30px; }
.heading { margin-bottom: 30px; text-align: center; }
.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
.setup p { color: var(--ops-muted); line-height: 1.6; }
.brand { display: flex; align-items: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
.brand { display: flex; align-items: center; justify-content: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
.brand svg { width: 38px; height: 38px; }
.panel { padding: 24px; margin: 16px 0; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); min-width: 0; }
.accountPanel { width: 100%; max-width: 560px; box-sizing: border-box; margin-left: auto; margin-right: auto; }
.panel h2, .panel h3 { margin-top: 0; }
.panel summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; list-style: none; }
.panel summary::after { content: "+"; color: var(--ops-primary-2); }
@@ -24,7 +25,18 @@
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
.toggle p { grid-column: 1 / -1; }
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
.account { display: grid; gap: 20px; max-width: 440px; margin: 24px 0; }
.account { display: grid; gap: 20px; margin: 24px 0; }
.tokenHelp { min-width: 0; }
.tokenHelpDialog { width: min(560px, calc(100% - 32px)); max-height: calc(100dvh - 48px); box-sizing: border-box; margin: auto; padding: 24px; overflow-y: auto; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); color: var(--ops-text); text-align: left; }
.tokenHelpDialog::backdrop { background: rgb(0 0 0 / 65%); }
.tokenHelpDialog p { margin: 14px 0; font-size: 14px; }
.tokenHelpHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.tokenHelpHeading h2 { margin: 0; font-size: 22px; }
.tokenHelpHeading button { flex-shrink: 0; }
.tokenHelpSteps { padding-left: 22px; color: var(--ops-muted); font-size: 14px; line-height: 1.6; }
.tokenHelpSteps li + li { margin-top: 10px; }
.field .tokenCommand { display: block; margin: 8px 0 12px; resize: none; font-family: monospace; }
.tokenHelpDialog .copyStatus { min-height: 1.6em; color: var(--ops-primary-2); }
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 30px; }
.steps button { flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px; background: var(--ops-panel); color: var(--ops-muted); border: 1px solid var(--ops-line); box-shadow: none; }
.steps button[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
@@ -43,6 +55,7 @@
.setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; }
.tokenHelpDialog { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; }