improvements for first-time setup
This commit is contained in:
parent
e59d85cf57
commit
979d27ec7a
16 changed files with 901 additions and 33 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
9
cmd/telesrv-admin/web/dist/assets/index-B8l4nVOK.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-B8l4nVOK.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
396
cmd/telesrv-admin/web/src/components/SetupWizard.tsx
Normal file
396
cmd/telesrv-admin/web/src/components/SetupWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue