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
+5 -4
View File
@@ -10,10 +10,9 @@ Paste [compose.yml](compose.yml) into a Portainer **Docker Standalone** stack.
It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no It uses `rephl3xnz/magent:latest`, persists data in a named volume and needs no
environment variables or Dockerfile on the user's machine. environment variables or Dockerfile on the user's machine.
**Image availability:** this source snapshot has not yet been published to **Image availability:** the managed-install image is published on Docker Hub.
Docker Hub. The current `latest` image may not include this setup flow. Until a Only Linux/amd64 has been validated. `latest` is mutable; record the resolved
compatible image is published, use the source-build command below for testing. image digest before updating, or pin an immutable release tag.
Only Linux/amd64 has been validated.
1. Deploy the stack and wait for the container to become healthy. 1. Deploy the stack and wait for the container to become healthy.
2. In its console, select `/bin/ash` and user `magent`, then run: 2. In its console, select `/bin/ash` and user `magent`, then run:
@@ -24,6 +23,8 @@ Only Linux/amd64 has been validated.
3. Open the Docker host's address on port 3000. Confirm the browser-facing URL 3. Open the Docker host's address on port 3000. Confirm the browser-facing URL
in setup and use the token to create the first administrator. in setup and use the token to create the first administrator.
The **Get setup token** button shows the console instructions and lets you
copy the command; it never reveals the token to public visitors.
4. Connect your apps, choose preferences and finish setup. Optional apps can 4. Connect your apps, choose preferences and finish setup. Optional apps can
be skipped. Save an encrypted backup afterwards. be skipped. Save an encrypted backup afterwards.
+9 -5
View File
@@ -8,11 +8,11 @@ through administrator and app setup. Users do not need a Dockerfile, source
checkout, Python installation, `.env` file or another database container. checkout, Python installation, `.env` file or another database container.
**Release availability:** the image with zero-input managed bootstrap and **Release availability:** the image with zero-input managed bootstrap and
wizard-configured origins still needs publishing to Docker Hub. These repository wizard-configured origins is published on Docker Hub. `latest` is mutable, not
changes have not published an image or changed a live deployment. Until the an immutable release identifier; record the deployed digest and review release
release owner updates `latest` with this release, the template may pull an older notes before updating. If an existing stack still uses an older image, updating
image that lacks these features. `latest` is mutable, not an immutable release the Compose text alone does not pull the new image: explicitly re-pull it when
identifier; record the deployed digest and review release notes before updating. redeploying, while retaining the same persistent volume.
## Requirements ## Requirements
@@ -49,6 +49,10 @@ identifier; record the deployed digest and review release notes before updating.
private: anyone with that token and access to an unclaimed installation can private: anyone with that token and access to an unclaimed installation can
create its first administrator. The command refuses to reveal it once an create its first administrator. The command refuses to reveal it once an
administrator exists. administrator exists.
The setup page's **Get setup token** button also shows these instructions and
offers **Copy command**. On browsers without clipboard access, select and
copy the displayed command manually. This help dialog does not generate or
reveal a token over the public web interface.
5. Open Magent at the browser address you intend to use, such as 5. Open Magent at the browser address you intend to use, such as
`http://192.168.1.50:3000` on a trusted LAN or `https://magent.example.com` `http://192.168.1.50:3000` on a trusted LAN or `https://magent.example.com`
through your configured HTTPS proxy. Use the **Docker host's reachable through your configured HTTPS proxy. Use the **Docker host's reachable
+4 -4
View File
@@ -31,10 +31,10 @@ token-authorized first-administrator wizard; matching CORS and cookie security
follow the saved URL automatically. Managed SQLite stays at follow the saved URL automatically. Managed SQLite stays at
`/app/data/magent.db`, and API documentation stays disabled. `/app/data/magent.db`, and API documentation stays disabled.
**Publication is still pending:** the new image must be published before the The managed-install image is published on Docker Hub. Repository changes alone
`latest` stack can supply this behaviour. Repository changes do not publish or do not update an existing deployment: explicitly pull the image when upgrading,
deploy an image; an older `latest` image remains incompatible with this new retaining the same persistent volume. `latest` is mutable, so record the resolved
workflow. `latest` is mutable, so record the resolved digest before an upgrade. digest before an upgrade; older cached images may lack this setup workflow.
The Compose runtime-security block stays enabled and must be left unchanged. The Compose runtime-security block stays enabled and must be left unchanged.
See the [complete environment reference](ENVIRONMENT.md) for all supported See the [complete environment reference](ENVIRONMENT.md) for all supported
variables and the difference between managed, manual and tooling controls. variables and the difference between managed, manual and tooling controls.
+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 { apiUrl, requestJson } from "../lib/api-client";
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth"; import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
import MagentMark from "../ui/MagentMark"; import MagentMark from "../ui/MagentMark";
import SetupTokenHelp from "./SetupTokenHelp";
import { serviceStatusLabel } from "../admin/configNavigation"; import { serviceStatusLabel } from "../admin/configNavigation";
import { import {
ALL_FIELDS, ALL_FIELDS,
@@ -352,7 +353,7 @@ export default function SetupPage() {
Retry Retry
</button> </button>
) : forbidden ? ( ) : forbidden ? (
<section className={styles.panel}> <section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>Administrator access required</h2> <h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p> <p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}> <button type="button" disabled={!!busy} onClick={switchAccount}>
@@ -360,19 +361,12 @@ export default function SetupPage() {
</button> </button>
</section> </section>
) : !admin ? ( ) : !admin ? (
<section className={styles.panel}> <section className={`${styles.panel} ${styles.accountPanel}`}>
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2> <h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
<p> <p>
{status.needs_admin ? ( {status.needs_admin
<> ? "Use your private setup token to create the first administrator account."
Enter the SETUP_TOKEN from your deployment environment. For a managed Portainer install, open the : "Use your local Magent administrator account. Settings are never available to unauthenticated visitors."}
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."
)}
</p> </p>
<form onSubmit={authenticate} className={styles.account}> <form onSubmit={authenticate} className={styles.account}>
{status.needs_admin && ( {status.needs_admin && (
@@ -409,8 +403,11 @@ export default function SetupPage() {
maxLength={1024} maxLength={1024}
value={setupToken} value={setupToken}
onChange={(event) => setSetupToken(event.target.value)} onChange={(event) => setSetupToken(event.target.value)}
aria-describedby="setup-token-hint"
disabled={!!busy} disabled={!!busy}
/> />
<p id="setup-token-hint">Retrieve this token from your server console.</p>
<SetupTokenHelp disabled={!!busy} />
</div> </div>
)} )}
<div className={styles.field}> <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); } .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; } .heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
.setup p { color: var(--ops-muted); line-height: 1.6; } .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; } .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; } .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 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 { 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); } .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 { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
.toggle p { grid-column: 1 / -1; } .toggle p { grid-column: 1 / -1; }
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; } .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 { 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 { 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); } .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; } .setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; } .fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; } .panel { padding: 18px; }
.tokenHelpDialog { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; } .steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; } .badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; } .panel summary { gap: 10px; }