improvements for first-time setup

This commit is contained in:
onysd 2026-09-08 17:56:26 +03:00
parent e59d85cf57
commit 979d27ec7a
16 changed files with 901 additions and 33 deletions

View file

@ -2,10 +2,14 @@ package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"telesrv/internal/identity"
)
func TestValidateAdminPassword(t *testing.T) {
@ -203,6 +207,57 @@ func TestBreakGlassUsernameIsCaseInsensitive(t *testing.T) {
}
}
// The break-glass password quickstart generates for the very first login
// must stop authenticating once the first-run wizard is done, but a
// password an operator actually chose -- even one that happens to still be
// sitting in .env from before the wizard finished -- must never be
// affected by that. This is the actual integration point between
// validSecret and identity.Store; the package's own tests cover
// SetupPending/TemporaryPasswordMatches in isolation.
func TestValidSecretRetiresOnlyTheGeneratedPassword(t *testing.T) {
dir := t.TempDir()
store := identity.NewStore(dir)
s := &server{cfg: uiConfig{Password: "generated-once", Permissions: []string{permissionAll}}, identity: store}
// No marker written at all yet (identity.Store's zero state) -- the
// password behaves like an ordinary one an operator set.
if !s.validSecret("generated-once") {
t.Fatal("password should authenticate before any wizard marker exists")
}
// Bootstrap-style: the setup-pending marker plus the matching
// temporary-password marker, exactly as tui-panel/server-panel.py's
// bootstrap_env() writes them on a fresh install.
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".setup_pending"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".admin_password_temporary"), []byte("generated-once"), 0o644); err != nil {
t.Fatal(err)
}
if !s.validSecret("generated-once") {
t.Fatal("the generated password must keep working while the wizard is still pending")
}
// Wizard finishes: MarkSetupComplete removes both markers.
if err := store.MarkSetupComplete(); err != nil {
t.Fatal(err)
}
if s.validSecret("generated-once") {
t.Fatal("the generated password must stop authenticating once setup is complete")
}
// An operator-chosen password behaves normally regardless: setting a
// new .env value (this test's stand-in for that) authenticates whether
// or not a wizard ever ran, because it never matches either marker.
s.cfg.Password = "an-operator-actually-chose-this"
if !s.validSecret("an-operator-actually-chose-this") {
t.Fatal("an operator-chosen password must authenticate after setup completion, same as always")
}
}
// A session for a named account must not be trusted on the strength of its
// signature alone: the account's rights are re-read per request, and a nil read
// store has to fail closed rather than fall back to the claims.

View file

@ -202,6 +202,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
mux.Handle("POST /api/actions/complete-setup", s.serverManage(s.handleCompleteSetupAPI))
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
@ -314,6 +315,16 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
func (s *server) validSecret(secret string) bool {
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
// The password quickstart auto-generates for the very first login
// (see identity.Store.TemporaryPasswordMatches) is only good until
// the first-run wizard finishes -- one login's worth of "how do I
// even get in", not a credential anyone actually chose to keep
// around. A password an operator set on purpose, whether by saving
// one from Server Settings or editing .env by hand, never matches
// the stored generated value, so this never touches it.
if s.identity.TemporaryPasswordMatches(s.cfg.Password) && !s.identity.SetupPending() {
return false
}
return true
}
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
@ -352,6 +363,10 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
"actor": actorFromContext(r.Context()),
"permissions": permissionsFromContext(r.Context()).List(),
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
// setup_completed gates the first-run wizard -- see
// identity.Store.SetupPending's doc comment for why this reads a
// sentinel file rather than anything in identity.json itself.
"setup_completed": !s.identity.SetupPending(),
// boot_id is random per process start (see main.go) -- Server
// Settings' Restart/Update flow polls this after triggering an
// action and reloads the page once it changes, which is how it

View file

@ -290,6 +290,35 @@ func (s *server) handleRemoveServerIconAPI(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
}
// --- first-run setup wizard ----------------------------------------------
type completeSetupAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
}
// handleCompleteSetupAPI is the wizard's last step: removes the
// identity.Store setup-pending marker so /api/session stops telling the
// frontend to show it. Everything the wizard actually configures (identity,
// .env, the operator account) is already saved as the operator moves
// through it via the same actions Server Settings/Operators use outside the
// wizard -- this action only marks that the walkthrough happened, so it
// never fails partway through something worth retrying.
func (s *server) handleCompleteSetupAPI(w http.ResponseWriter, r *http.Request) {
var body completeSetupAPIRequest
if !decodeAction(w, r, &body) {
return
}
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "complete-setup")
if meta.DryRun {
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", nil, "setup completion validated", nil))
return
}
err := s.identity.MarkSetupComplete()
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", err, "setup marked complete", nil))
}
// --- .env editing --------------------------------------------------------
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-hP2uBwK-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQqynmV9.css">
<script type="module" crossorigin src="/assets/index-B8l4nVOK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DW3ERnl8.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { api } from "./api";
import { BootScreen, Shell } from "./components/Layout";
import { SetupWizard } from "./components/SetupWizard";
import { LoginPage } from "./pages/LoginPage";
import { PermissionsProvider } from "./permissions";
import { permissionAll, permissionServerManage, PermissionsProvider } from "./permissions";
import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing";
import type { AdminSession } from "./types";
@ -55,6 +56,18 @@ export function App() {
);
}
// The wizard only ever shows to whoever can actually act on it -- a
// limited operator signing in before setup is finished just sees the
// normal (mostly empty) shell instead of a wizard whose every step would
// 403. setup_completed undefined (an admin binary old enough to predate
// the field) reads as "done", same convention as the type's doc comment.
const canRunSetupWizard = (session.permissions ?? []).some(
(permission) => permission === permissionAll || permission === permissionServerManage
);
if (session.setup_completed === false && canRunSetupWizard) {
return <SetupWizard />;
}
return (
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
<Shell actor={session.actor} apiLayers={session.api_layers} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>

View file

@ -0,0 +1,396 @@
import { ArrowRight, Check, ImagePlus, Loader2, Rocket, UserPlus } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { RestartOverlay, ServerIconModal, useAdminRestartWatcher } from "../pages/ServerSettingsPage";
import { ThemeSwitch } from "../theme";
import { AppBackground } from "./AppBackground";
import { Alert } from "./ui";
// The first-run wizard: shown instead of the normal shell exactly once, when
// GET /api/session answers setup_completed=false (see
// identity.Store.SetupPending and cmd/telesrv-admin/server.go's
// handleSession). It covers the same ground
// tui-panel/server-panel.py's SetupWizardScreen used to ask for in the
// terminal before the server could even start -- server identity, the public
// network fields a real deployment needs, and a named operator account to
// replace the generated break-glass password -- except every field here
// already has a working default (see quickstart's bootstrap_env), so nothing
// blocks Start anymore. This just walks through customizing it.
//
// Every step's "Continue" calls the same /api/actions/* route the equivalent
// Server Settings / Operators screen uses, with a fixed reason instead of an
// operator-typed one and confirm:true immediately (no dry-run screen). The
// rest of the panel asks an operator to justify a change to a live,
// populated deployment; here the operator IS the only account that has ever
// existed, configuring a server nobody else is using yet -- asking "why are
// you naming your own server" is friction with no audit value.
const WIZARD_REASON = "Set from the first-run setup wizard";
type StepId = "welcome" | "identity" | "network" | "account" | "done";
const STEP_ORDER: StepId[] = ["welcome", "identity", "network", "account", "done"];
const STEP_LABEL: Record<StepId, string> = {
welcome: "Welcome",
identity: "Identity",
network: "Network",
account: "Account",
done: "Done"
};
// No prop for "leave the wizard early": this only ever shows on a genuinely
// first-ever start, before there is a real deployment for a Skip to defer
// anything about -- see the Welcome step. The only way out is Done's
// "Finish setup & restart", which reloads the page once the new process
// answers; that reload is what dismisses this component (a fresh
// /api/session comes back with setup_completed=true).
export function SetupWizard() {
const [step, setStep] = useState<StepId>("welcome");
function goTo(next: StepId) {
setStep(next);
}
return (
<main className="login-page setup-wizard-page">
<AppBackground />
<section className="login-panel setup-wizard-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span>
<strong>{"Let's set up your server"}</strong>
<small>{"First-run setup"}</small>
</span>
</div>
<div className="login-head-actions">
<ThemeSwitch />
</div>
</div>
<div className="wizard-steps">
{STEP_ORDER.map((id, index) => {
const currentIndex = STEP_ORDER.indexOf(step);
const state = index === currentIndex ? "active" : index < currentIndex ? "done" : "";
return (
<div key={id} className={`command-step ${state}`}>
<span>{index < currentIndex ? <Check size={12} /> : index + 1}</span>
<strong>{STEP_LABEL[id]}</strong>
</div>
);
})}
</div>
{step === "welcome" && <WelcomeStep onNext={() => goTo("identity")} />}
{step === "identity" && <IdentityStep onNext={() => goTo("network")} />}
{step === "network" && <NetworkStep onNext={() => goTo("account")} />}
{step === "account" && <AccountStep onNext={() => goTo("done")} />}
{step === "done" && <DoneStep />}
</section>
</main>
);
}
function WizardActions({ children }: { children: ReactNode }) {
return <div className="wizard-actions">{children}</div>;
}
function WelcomeStep({ onNext }: { onNext: () => void }) {
return (
<div className="wizard-step-body">
<p className="wizard-welcome-greeting">{"Hi!"}</p>
<p>
{"Let's get your server set up -- a name, an address for clients, and an account of "}
{"your own. Takes about a minute, and everything here stays editable later."}
</p>
<WizardActions>
<button className="btn primary icon-text" type="button" onClick={onNext}>
{"Get started"} <ArrowRight size={15} />
</button>
</WizardActions>
</div>
);
}
function IdentityStep({ onNext }: { onNext: () => void }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [iconExt, setIconExt] = useState<string | undefined>(undefined);
const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconBust, setIconBust] = useState(0);
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.serverIdentity()
.then((info) => {
if (cancelled) return;
setName(info.name);
setDescription(info.description);
setIconExt(info.icon_ext);
setLoaded(true);
})
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
return () => { cancelled = true; };
}, []);
async function submit() {
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/set-server-identity", {
command_id: "", reason: WIZARD_REASON, confirm: true, name, description
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">{"Shown to clients when they add this server, and in the sidebar here."}</p>
{error && <Alert>{error}</Alert>}
<div className="wizard-identity-row">
<div className="avatar-edit-slot">
{iconExt ? (
<img className="avatar-photo-img" src={api.serverIconURL() + `&b=${iconBust}`} alt="" style={{ width: 72, height: 72 }} />
) : (
<div className="avatar-fallback server-icon-fallback" style={{ width: 72, height: 72 }}>
<ImagePlus size={22} />
</div>
)}
<button className="icon-btn avatar-edit-btn" type="button" aria-label={"Add server icon"} onClick={() => setIconModalOpen(true)}>
<ImagePlus size={13} />
</button>
</div>
<div className="server-identity-fields">
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} placeholder={"OwpenGram"} disabled={!loaded} onChange={(event) => setName(event.target.value)} /></label>
<label className="form-field"><span>{"Description"}</span><textarea rows={2} value={description} maxLength={512} disabled={!loaded} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</div>
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
{"Continue"}
</button>
</WizardActions>
{iconModalOpen && (
<ServerIconModal
hasIcon={!!iconExt}
autoReason={WIZARD_REASON}
onClose={() => setIconModalOpen(false)}
onDone={() => { setIconBust((n) => n + 1); void api.serverIdentity().then((info) => setIconExt(info.icon_ext)); }}
/>
)}
</div>
);
}
const NETWORK_FIELDS: { key: string; label: string; hint: string; placeholder: string }[] = [
{
key: "TELESRV_ADVERTISE_IP",
label: "Server public IP or hostname",
hint: "What clients connect to. Fine to leave as 127.0.0.1 for local testing.",
placeholder: "127.0.0.1"
},
{
key: "TELESRV_PUBLIC_BASE_URL",
label: "Public base URL",
hint: "Used for links this server generates -- invites, sticker packs. e.g. https://example.com",
placeholder: "http://127.0.0.1"
},
{
key: "TELESRV_PUBLIC_APP_SCHEME",
label: "Custom app link scheme",
hint: "Must match what your client builds were compiled with.",
placeholder: "owpg"
}
];
function NetworkStep({ onNext }: { onNext: () => void }) {
const [values, setValues] = useState<Record<string, string>>({});
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
api.serverEnv()
.then((groups) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const group of groups) {
for (const field of group.fields) {
if (NETWORK_FIELDS.some((f) => f.key === field.key)) next[field.key] = field.value;
}
}
setValues(next);
setLoaded(true);
})
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
return () => { cancelled = true; };
}, []);
async function submit() {
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/update-server-env", {
command_id: "", reason: WIZARD_REASON, confirm: true, values
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">{"Takes effect once setup finishes below -- that last step restarts the server."}</p>
{error && <Alert>{error}</Alert>}
{NETWORK_FIELDS.map((field) => (
<label key={field.key} className="form-field env-field">
<span>{field.label}</span>
<span className="env-field-desc">{field.hint}</span>
<input
value={values[field.key] ?? ""}
placeholder={field.placeholder}
disabled={!loaded}
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
/>
</label>
))}
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
{"Continue"}
</button>
</WizardActions>
</div>
);
}
function AccountStep({ onNext }: { onNext: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const incomplete = username.trim().length < 3 || password.trim() === "";
async function submit() {
if (incomplete) return;
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/create-admin-operator", {
command_id: "", reason: WIZARD_REASON, confirm: true,
username: username.trim(), password, permissions: ["*"], enabled: true
});
if (result.error) {
setError(result.error);
return;
}
onNext();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p className="wizard-step-hint">{"Replace the generated password with a login of your own."}</p>
{error && <Alert>{error}</Alert>}
<label className="form-field"><span>{"Username"}</span><input autoFocus value={username} spellCheck={false} autoCapitalize="none" placeholder={"letters, digits, dot, dash or underscore"} onChange={(event) => setUsername(event.target.value)} /></label>
<label className="form-field"><span>{"Password"}</span><input type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} /></label>
<WizardActions>
<button className="btn" type="button" onClick={onNext}>{"Skip for now"}</button>
<button className="btn primary icon-text" type="button" disabled={busy || incomplete} onClick={() => void submit()}>
{busy ? <Loader2 className="spin" size={15} /> : <UserPlus size={15} />}
{"Create account & continue"}
</button>
</WizardActions>
</div>
);
}
// DoneStep marks setup complete and restarts, rather than leaving that for
// later -- the network fields two steps back only take effect after a
// restart, and asking the operator to remember to go find Restart in
// Services afterward is exactly the kind of loose end this wizard exists to
// close. useAdminRestartWatcher (shared with Services' own Restart button)
// reloads the page once a genuinely new process answers; passed a
// beforeReload that logs out first here specifically (Services' own Restart
// button doesn't), so the reload lands back on the login form instead of
// straight into the shell still signed in as "owpengram" -- the whole point
// of the Account step just before this one was to have a real login to
// switch to instead. The generated password stops working server-side the
// moment complete-setup runs (see identity.Store.TemporaryPasswordMatches),
// independent of this logout; this just makes sure the browser doesn't
// carry the old session forward and paper over that.
function DoneStep() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const restartWatcher = useAdminRestartWatcher();
async function finish() {
setBusy(true);
setError("");
try {
const completeResult = await api.action("/api/actions/complete-setup", { command_id: "", reason: WIZARD_REASON, confirm: true });
if (completeResult.error) {
setError(completeResult.error);
setBusy(false);
return;
}
const restartResult = await api.action("/api/actions/restart-server", { command_id: "", reason: WIZARD_REASON, confirm: true });
if (restartResult.error) {
setError(restartResult.error);
setBusy(false);
return;
}
void restartWatcher.watch(150000, { beforeReload: async () => { await api.logout(); } });
} catch (err) {
setError(errorMessage(err));
setBusy(false);
}
}
return (
<div className="wizard-step-body">
<p>
{"That's the essentials. Finishing restarts the server so the network settings from the previous "}
{"step take effect. Everything here stays editable from Server Settings and Operators any time."}
</p>
{error && <Alert>{error}</Alert>}
<WizardActions>
<button className="btn primary icon-text" type="button" disabled={busy} onClick={() => void finish()}>
{busy ? <Loader2 className="spin" size={15} /> : <Rocket size={15} />}
{"Finish setup & restart"}
</button>
</WizardActions>
{restartWatcher.waiting && (
<RestartOverlay label={"Restarting owpengram-server and the admin panel..."} timedOut={false} onDismiss={restartWatcher.dismiss} />
)}
{restartWatcher.timedOut && (
<RestartOverlay label={"Restarting owpengram-server and the admin panel..."} timedOut={true} onDismiss={restartWatcher.dismiss} />
)}
</div>
);
}

View file

@ -290,10 +290,14 @@ function LoginNotificationsSection() {
);
}
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
// autoReason skips the "why is this changing" prompt in favor of a fixed
// reason -- for the first-run wizard, where there is no prior state to
// justify changing away from and no one else's icon to be overwriting.
export function ServerIconModal({ hasIcon, onClose, onDone, autoReason }: { hasIcon: boolean; onClose: () => void; onDone: () => void; autoReason?: string }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
const [typedReason, setTypedReason] = useState("");
const reason = autoReason ?? typedReason;
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@ -375,7 +379,9 @@ function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClo
<span className="gift-file-copy"><span className="gift-field-label">{"New icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF image"}</strong></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setReason(event.target.value)} /></label>
{autoReason === undefined && (
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={typedReason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setTypedReason(event.target.value)} /></label>
)}
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
@ -495,12 +501,12 @@ function sleep(ms: number): Promise<void> {
// process is up, not just that the old one is still slow -- then reloading
// the page. A timeout surfaces as a message with a manual reload button
// instead of spinning forever if something went wrong server-side.
function useAdminRestartWatcher() {
export function useAdminRestartWatcher() {
const [waiting, setWaiting] = useState(false);
const [timedOut, setTimedOut] = useState(false);
const cancelled = useRef(false);
const watch = useCallback(async (timeoutMs = 150000) => {
const watch = useCallback(async (timeoutMs = 150000, options?: { beforeReload?: () => Promise<void> | void }) => {
cancelled.current = false;
setTimedOut(false);
setWaiting(true);
@ -518,6 +524,12 @@ function useAdminRestartWatcher() {
try {
const session = await api.session();
if (session.boot_id && session.boot_id !== baseline) {
// beforeReload runs against the new process (this session read
// already proved it's up) and can't fail the reload -- a reload
// an operator is staring at a spinner for shouldn't hang on it.
if (options?.beforeReload) {
await Promise.resolve(options.beforeReload()).catch(() => undefined);
}
window.location.reload();
return;
}
@ -539,7 +551,7 @@ function useAdminRestartWatcher() {
return { waiting, timedOut, watch, dismiss };
}
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
export function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>

View file

@ -476,6 +476,83 @@
}
}
/* First-run setup wizard (components/SetupWizard.tsx) -- built on the same
.login-page/.login-panel shell as the sign-in screen, just wider: this one
holds a name+description+icon row and env fields, not two single inputs. */
.setup-wizard-panel {
width: min(640px, 100%);
max-height: min(760px, calc(100vh - 48px));
overflow-y: auto;
}
.wizard-steps {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wizard-steps .command-step {
flex: 1 1 0;
justify-content: center;
min-width: 0;
padding: 0 6px;
font-size: 12.5px;
}
.wizard-steps .command-step strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wizard-step-body {
display: grid;
gap: 14px;
}
.wizard-step-body > p {
margin: 0;
color: var(--text-soft);
}
.wizard-step-body > p.wizard-step-hint {
color: var(--muted);
font-size: 13px;
}
.wizard-step-body > p.wizard-welcome-greeting {
color: var(--heading);
font-size: 24px;
font-weight: 800;
}
.wizard-identity-row {
display: flex;
align-items: flex-start;
gap: 16px;
}
.wizard-identity-row .server-identity-fields {
flex: 1 1 auto;
display: grid;
gap: 10px;
}
.wizard-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 560px) {
.wizard-steps .command-step strong {
display: none;
}
.wizard-identity-row {
flex-direction: column;
}
}
.boot-screen {
display: grid;
min-height: 100vh;

View file

@ -580,6 +580,11 @@ export type AdminSession = {
// 404, so this is a UI convenience on top of a real enforcement, not the
// enforcement itself.
hide_third_party_verification?: boolean;
// False until the first-run setup wizard has been finished -- see
// identity.Store.SetupPending. Missing/undefined is treated as true (an
// admin binary older than this field never gates on it), so only an
// explicit false shows the wizard.
setup_completed?: boolean;
// Random per admin-process-start value -- see the Go handler's doc
// comment. Used by Server Settings' Restart/Update flow to detect a
// genuinely new admin process after asking it to bounce.