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

@ -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.