feat(setup): center onboarding and add secure token help
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user