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

@ -140,8 +140,13 @@ TELESRV_ADMIN_SESSION_KEY=
# the admin panel entirely; set to a loopback address (127.0.0.1:...) to
# enable it without exposing it outside this machine.
TELESRV_ADMIN_API_ADDR=
# Address the admin panel's own web UI listens on.
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600
# Address the admin panel's own web UI listens on. 0.0.0.0 so a fresh
# install is reachable right away from wherever you're setting it up from --
# a VPS you're provisioning from across the world included -- without an SSH
# tunnel just to see the first-run wizard. Narrow it to a loopback or LAN
# address once you're done if you'd rather it not be open to the internet;
# the login itself still needs the password (or token) below either way.
TELESRV_ADMIN_UI_ADDR=0.0.0.0:2600
# Permissions granted to an Admin UI session that logged in with
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
# permission and is the default, so enabling RBAC never locks an operator out of

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.

View file

@ -9,6 +9,7 @@
package identity
import (
"crypto/subtle"
"encoding/json"
"fmt"
"os"
@ -19,6 +20,29 @@ import (
const (
metaFileName = "identity.json"
iconBaseName = "icon"
// setupPendingFileName marks an install as not yet through the
// first-run wizard. Deliberately a sentinel file next to identity.json,
// not a field inside it: identity.json's own content changes *during*
// the wizard -- the Identity step saves a name well before Done is ever
// reached -- so a signal derived from that content (e.g. "name is set")
// flips to "done" the moment that one step is saved, not when the
// wizard actually finishes. This file is created once, by quickstart's
// bootstrap_env() the moment it creates a fresh .env (see
// tui-panel/server-panel.py), and removed once, by MarkSetupComplete --
// nothing in between (including a server restart mid-wizard) touches
// it, so "still pending" survives every step until Done really is
// reached.
setupPendingFileName = ".setup_pending"
// passwordTemporaryFileName holds the exact value of the password
// quickstart's bootstrap_env() generated for the very first login on a
// fresh install (see tui-panel/server-panel.py) -- created alongside
// setupPendingFileName, never on its own. Storing the value itself
// (rather than just the file's existence) is what lets
// TemporaryPasswordMatches tell "still the generated one" apart from
// "an operator has since set their own", however that happened -- a
// manually typed .env edit included, since that never goes through this
// package at all.
passwordTemporaryFileName = ".admin_password_temporary"
)
// Info is the editable identity shown to clients.
@ -71,6 +95,49 @@ func (s *Store) iconPath(ext string) string {
return filepath.Join(s.dir, iconBaseName+ext)
}
func (s *Store) setupPendingPath() string {
return filepath.Join(s.dir, setupPendingFileName)
}
func (s *Store) passwordTemporaryPath() string {
return filepath.Join(s.dir, passwordTemporaryFileName)
}
// SetupPending reports whether the first-run wizard still has work to do.
// A deployment that predates this feature (upgraded from an older admin
// binary, or one that was never bootstrapped through quickstart at all)
// never had this file created for it, so it reads as "not pending" --
// already done, no wizard -- regardless of what its identity.json happens
// to contain. A nil Store (a minimal test fixture, say) reads the same way
// -- "not pending" is the answer that costs nothing if it's wrong.
func (s *Store) SetupPending() bool {
if s == nil {
return false
}
_, err := os.Stat(s.setupPendingPath())
return err == nil
}
// TemporaryPasswordMatches reports whether password is exactly the value
// quickstart auto-generated for the very first login. cmd/telesrv-admin's
// validSecret pairs this with SetupPending: the generated password
// authenticates only until the wizard finishes, so a string that was
// printed once to a terminal and never chosen by anyone doesn't go on
// being a standing credential forever. It never matches a password an
// operator set themselves, at any point -- there's no file to fool it
// with, only an exact value comparison. A nil Store never matches, same
// reasoning as SetupPending.
func (s *Store) TemporaryPasswordMatches(password string) bool {
if s == nil || password == "" {
return false
}
stored, err := os.ReadFile(s.passwordTemporaryPath())
if err != nil {
return false
}
return subtle.ConstantTimeCompare(stored, []byte(password)) == 1
}
// Get reads the current identity. A missing file is not an error -- it just
// means nothing has been configured yet, so Info{} (all empty) is returned.
func (s *Store) Get() (Info, error) {
@ -136,6 +203,25 @@ func (s *Store) SetLoginCodeMessageTemplate(template string) error {
return s.save(info)
}
// MarkSetupComplete removes the pending marker so SetupPending reads false
// from here on. Idempotent -- calling it again once the marker is already
// gone is a no-op, not an error.
//
// Deliberately leaves the temporary-password marker in place: validSecret
// needs TemporaryPasswordMatches to keep recognizing that exact value
// *after* setup completes, which is the whole mechanism that retires it --
// deleting the marker here would make that check quietly stop matching and
// the password would keep working forever, the opposite of the point.
// Nothing about leaving it costs anything: it never matches a different
// password (an operator's real one, whenever they set it), and this
// package's only reader of it is that one comparison.
func (s *Store) MarkSetupComplete() error {
if err := os.Remove(s.setupPendingPath()); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("identity: remove setup-pending marker: %w", err)
}
return nil
}
// SetIcon replaces the icon file (removing any previous one under a
// different extension) and records its extension in identity.json.
// ext must include the leading dot (e.g. ".png").

View file

@ -197,3 +197,98 @@ func TestStoreWelcomeMessageTemplatesPreservedAcrossTextEdits(t *testing.T) {
t.Fatalf("welcome message templates lost after unrelated SetText: %+v", info)
}
}
// TestStoreSetupPendingSurvivesIdentityWrites is the regression case for the
// bug where "setup complete" was inferred from identity.json's own content
// (a non-empty name): the wizard's own Identity step calls SetText well
// before Done is ever reached, which made that content-based check flip to
// "done" mid-wizard -- a restart-and-reload partway through skipped
// straight to the normal shell. SetupPending must stay true across any
// number of unrelated identity writes and clear only via MarkSetupComplete.
func TestStoreSetupPendingSurvivesIdentityWrites(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
// Not pending at all until something (quickstart's bootstrap_env, in
// production) creates the marker -- an install this store never saw
// bootstrapped is treated as predating the wizard, not as mid-wizard.
if s.SetupPending() {
t.Fatal("expected SetupPending() == false before the marker file exists")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, setupPendingFileName), nil, 0o644); err != nil {
t.Fatal(err)
}
if !s.SetupPending() {
t.Fatal("expected SetupPending() == true once the marker file exists")
}
// The Identity step's save, and everything else short of Done.
if err := s.SetText("Demo Server", "A test server"); err != nil {
t.Fatal(err)
}
if err := s.SetIcon([]byte{1, 2, 3}, ".png"); err != nil {
t.Fatal(err)
}
if !s.SetupPending() {
t.Fatal("expected SetupPending() to stay true after unrelated identity writes")
}
if err := s.MarkSetupComplete(); err != nil {
t.Fatal(err)
}
if s.SetupPending() {
t.Fatal("expected SetupPending() == false after MarkSetupComplete")
}
// Idempotent: calling it again once already gone is not an error.
if err := s.MarkSetupComplete(); err != nil {
t.Fatalf("MarkSetupComplete should be idempotent, got: %v", err)
}
}
// TestStoreTemporaryPasswordMatches covers the one-time-login password
// quickstart generates: it must match only its own exact value, never an
// operator-chosen one. MarkSetupComplete deliberately does NOT erase this
// marker (see that method's doc comment) -- retiring the password is
// validSecret's job, combining this with SetupPending; on its own,
// TemporaryPasswordMatches keeps recognizing the same stored value even
// after the wizard finishes, which is exactly what lets that combination
// work at every login from then on, not just the first one after Done.
func TestStoreTemporaryPasswordMatches(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if s.TemporaryPasswordMatches("anything") {
t.Fatal("expected no match before the marker file exists")
}
if s.TemporaryPasswordMatches("") {
t.Fatal("an empty password must never match")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, passwordTemporaryFileName), []byte("generated-pw-123"), 0o644); err != nil {
t.Fatal(err)
}
if !s.TemporaryPasswordMatches("generated-pw-123") {
t.Fatal("expected a match against the exact stored value")
}
if s.TemporaryPasswordMatches("something-an-operator-typed") {
t.Fatal("a different password must never match the marker")
}
if err := s.MarkSetupComplete(); err != nil {
t.Fatal(err)
}
if !s.TemporaryPasswordMatches("generated-pw-123") {
t.Fatal("expected the match to survive MarkSetupComplete -- see its doc comment for why")
}
if s.SetupPending() {
t.Fatal("expected SetupPending() == false after MarkSetupComplete regardless")
}
}

View file

@ -51,6 +51,13 @@ from textual.widgets.option_list import Option
IS_WINDOWS = platform.system() == "Windows"
# Mirrors breakGlassUsername in cmd/telesrv-admin/adminauth.go -- the fixed
# name authenticateLogin resolves against TELESRV_ADMIN_UI_PASSWORD/_TOKEN
# rather than a named operator row, i.e. whatever quickstart just generated
# a password for. Printed alongside that password so "log in with what" has
# an answer -- the login form itself has no default filled in.
ADMIN_BREAK_GLASS_USERNAME = "owpengram"
# psutil.cpu_percent()'s first call always returns a meaningless 0.0 baseline
# (it measures against process start); priming it once here means the first
# real reading in the stats timer is already a proper since-last-call delta.
@ -64,6 +71,21 @@ ENV_FILE = ROOT / ".env"
ENV_EXAMPLE_FILE = ROOT / ".env.example"
COMPOSE_FILE = DEPLOY_DIR / "docker-compose.yml"
STATE_FILE = ROOT / ".server_panel.json"
# Matches .env.example's TELESRV_IDENTITY_DIR default -- reliable at
# bootstrap time specifically because .env doesn't exist yet, so nothing
# could have overridden it. See SETUP_PENDING_FILE below.
IDENTITY_DIR = ROOT / "data" / "identity"
# Marks an install as not yet through the first-run wizard -- see
# internal/identity.Store.SetupPending's doc comment for the full reasoning
# (identity.json's own content changes mid-wizard, well before Done, so it
# can't be the signal). bootstrap_env() creates this file; the wizard's own
# complete-setup action (identity.Store.MarkSetupComplete) removes it.
SETUP_PENDING_FILE = IDENTITY_DIR / ".setup_pending"
# Holds the exact value of the password bootstrap_env() generates below, so
# identity.Store.TemporaryPasswordMatches can tell it apart from a password
# an operator actually chose -- see that method's doc comment. Written with
# no trailing newline; the Go side compares raw bytes.
PASSWORD_TEMPORARY_FILE = IDENTITY_DIR / ".admin_password_temporary"
SERVER_EXE = BIN_DIR / ("owpengram-server.exe" if IS_WINDOWS else "owpengram-server")
ADMIN_EXE = BIN_DIR / ("owpengram-admin-panel.exe" if IS_WINDOWS else "owpengram-admin-panel")
@ -631,19 +653,77 @@ def server_public_key_pem() -> str | None:
def bootstrap_env() -> str | None:
"""Creates .env from .env.example if this is a fresh install. Returns
the freshly generated admin password, or None if .env already existed
(nothing was generated or touched -- an existing password is never read
back for display, generated or not)."""
if is_initialized():
return None
"""Creates .env from .env.example on a fresh install, or -- just as
important -- patches in whichever of the three secrets telesrv-admin
refuses to boot without (TELESRV_ADMIN_API_TOKEN,
TELESRV_ADMIN_SESSION_KEY, and a password/token pair) are missing from
an .env that already exists. That second case is not hypothetical: an
.env can predate these fields entirely (hand-written before this admin
binary existed, or missing them for any other reason), and until this
ran, quickstart's own "already exists, nothing to do" check skipped
them forever -- the admin panel would launch and immediately exit on
main.go's "TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is
required", with quickstart none the wiser (launch() doesn't check the
child's exit code) and "Launched." printed anyway.
Also fills in TELESRV_ADMIN_API_ADDR when it's blank, matching
cmd/telesrv-admin/main.go's own defaultAdminAPIAddr (127.0.0.1:2599):
that's owpengram-server's *internal* admin API -- the one the admin
panel calls into for anything the read-only Postgres connection can't
serve on its own, e.g. proxying an account's live avatar bytes, or any
of the domain mutations (freeze, grant premium, ...). .env.example
ships it blank on purpose, so admin-panel functionality that depends on
it silently no-ops (an <img> just falls back to initials; a mutation
action surfaces a "connection refused") until someone notices and sets
it -- there's no reason to make a self-hoster running both binaries
together, which is exactly what quickstart does, discover and fix that
by hand.
Returns the freshly generated admin password if one was generated
(fresh install, or an existing .env that had neither a password nor a
token), or None if nothing needed generating -- an existing password is
never read back for display either way.
SETUP_PENDING_FILE (which gates the first-run wizard) and
PASSWORD_TEMPORARY_FILE (which makes that generated password stop
working once the wizard finishes -- see
identity.Store.TemporaryPasswordMatches) are only ever written on the
fresh-install branch. Patching secrets into an .env that was already
there isn't a first run -- there is likely already a real identity,
real data, real users behind it -- so it must never force that install
through the wizard, and a password generated to plug that gap has to
go on working indefinitely (nothing will ever run MarkSetupComplete to
retire it, since SetupPending was never true for it in the first
place)."""
fresh_install = not is_initialized()
values = current_env_values(parse_env_template())
admin_password = secrets.token_urlsafe(12)
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
values["TELESRV_ADMIN_UI_PASSWORD"] = admin_password
generated_password = None
changed = False
if not values.get("TELESRV_ADMIN_UI_PASSWORD") and not values.get("TELESRV_ADMIN_UI_TOKEN"):
generated_password = secrets.token_urlsafe(12)
values["TELESRV_ADMIN_UI_PASSWORD"] = generated_password
changed = True
if not values.get("TELESRV_ADMIN_API_TOKEN"):
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
changed = True
if not values.get("TELESRV_ADMIN_SESSION_KEY"):
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
changed = True
if not values.get("TELESRV_ADMIN_API_ADDR"):
values["TELESRV_ADMIN_API_ADDR"] = "127.0.0.1:2599"
changed = True
if not fresh_install and not changed:
return None # existing .env, and every required secret was already set
save_env(values)
return admin_password
if fresh_install:
IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
SETUP_PENDING_FILE.touch()
if generated_password:
PASSWORD_TEMPORARY_FILE.write_text(generated_password)
return generated_password
def _run_naming_helper(cmd: list[str]) -> str:
@ -730,8 +810,8 @@ def quickstart() -> int:
url, _ = info
print(f"Open {url} to finish setting up your server.")
if generated_password:
print(f"Login: {ADMIN_BREAK_GLASS_USERNAME}")
print(f"Initial admin password: {generated_password}")
print("(create a named operator account, or change this one, from Operators in the admin panel)")
print()
print("For stop/restart/logs/.env editing from the terminal instead: owpengram-server.bat panel")
return 0