server admin panel is now supports multiple operators profiles

This commit is contained in:
onysd 2026-09-08 01:01:35 +03:00
parent e48160ac3a
commit 280321b902
25 changed files with 2197 additions and 128 deletions

View file

@ -0,0 +1,393 @@
import { KeyRound, Lock, RefreshCw, ShieldCheck, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, LoadingRow, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { groupPermissions, permissionHint, permissionTitle } from "../permissions";
import type { AdminConsoleUser, AdminConsoleSystemOperator } from "../types";
// The operator-accounts screen. The table only reports; every change happens in
// a modal and goes through the panel's usual reason + dry-run + confirm flow,
// because handing somebody the run of the console deserves the same "here is
// what this will do" step as freezing an account.
//
// Everything here is additionally enforced server-side by admins.manage --
// hiding the section is a convenience, not the boundary.
export function AdminUsersPage() {
const [rows, setRows] = useState<AdminConsoleUser[]>([]);
const [system, setSystem] = useState<AdminConsoleSystemOperator | null>(null);
const [available, setAvailable] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
const [editing, setEditing] = useState<AdminConsoleUser | null>(null);
const [resetting, setResetting] = useState<AdminConsoleUser | null>(null);
const [creating, setCreating] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
const result = await api.adminUsers();
setRows(result.rows ?? []);
setSystem(result.system ?? null);
setAvailable(result.available_permissions ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
setLoaded(true);
}
}
useEffect(() => {
void load();
}, []);
return (
<PageFrame eyebrow={"ACCESS / OPERATORS"} title={"Admin operators"}>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="toolbar">
<button className="btn primary icon-text" type="button" onClick={() => setCreating(true)}>
<UserPlus size={15} /> {"New operator"}
</button>
<button className="btn icon-text" type="button" onClick={() => void load()} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</div>
</QueryPanel>
<SectionHead title={"Operators"} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Can do"}</th>
<th>{"Status"}</th>
<th>{"Last login"}</th>
<th></th>
</tr>
</thead>
<tbody>
{/* The built-in operator first: it has the most rights and no
database row, so a list that started with the named accounts
would put the most powerful login last, or nowhere. */}
{system && (
<tr>
<td className="mono">
{system.username} <span className="pill">{"built-in"}</span>
</td>
<td><PermissionChips permissions={system.permissions} /></td>
<td><span className="pill good">{"Enabled"}</span></td>
<td className="mono">{"—"}</td>
<td>
<span className="muted icon-text">
<Lock size={13} /> {"Set in the server environment"}
</span>
</td>
</tr>
)}
{rows.map((row) => (
<tr key={row.id}>
<td className="mono">{row.username}</td>
<td><PermissionChips permissions={row.permissions} /></td>
<td>
{row.enabled
? <span className="pill good">{"Enabled"}</span>
: <span className="pill">{"Disabled"}</span>}
</td>
<td className="mono">{row.last_login_at ? new Date(row.last_login_at).toLocaleString() : "—"}</td>
<td>
<button className="btn icon-text" type="button" onClick={() => setEditing(row)}>
<ShieldCheck size={14} /> {"Access"}
</button>
<button className="btn icon-text" type="button" onClick={() => setResetting(row)}>
<KeyRound size={14} /> {"Password"}
</button>
</td>
</tr>
))}
{rows.length === 0 && !system &&
(busy || !loaded ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
</tbody>
</table>
</div>
{creating && (
<OperatorModal
title={"New operator"}
available={available}
onClose={() => setCreating(false)}
onDone={() => { setCreating(false); void load(); }}
/>
)}
{editing && (
<OperatorModal
title={`Access for ${editing.username}`}
available={available}
existing={editing}
onClose={() => setEditing(null)}
onDone={() => { setEditing(null); void load(); }}
/>
)}
{resetting && (
<PasswordModal
operator={resetting}
onClose={() => setResetting(null)}
onDone={() => { setResetting(null); void load(); }}
/>
)}
</PageFrame>
);
}
function PermissionChips({ permissions }: { permissions: string[] }) {
if (permissions.length === 0) {
return <span className="muted">{"nothing yet"}</span>;
}
return (
<span className="chip-row">
{permissions.map((p) => (
<span className="chip" key={p} title={p}>{permissionTitle(p)}</span>
))}
</span>
);
}
// PermissionPicker lists the rights by what they let someone do, split into the
// section of the console each governs -- twenty-six checkboxes in one run is a
// wall nobody reads, and the grouping is what makes "what can this person
// actually touch" answerable at a glance.
//
// The raw permission string stays as each row's tooltip, so the screen never
// hides what is actually being stored.
function PermissionPicker({
available,
selected,
onToggle,
onToggleGroup
}: {
available: string[];
selected: string[];
onToggle: (permission: string, on: boolean) => void;
onToggleGroup: (permissions: string[], on: boolean) => void;
}) {
return (
<div className="permission-groups">
{groupPermissions(available).map((group) => {
const all = group.permissions.every((p) => selected.includes(p));
return (
<section className="permission-group" key={group.title}>
<div className="permission-group-head">
<div>
<strong>{group.title}</strong>
<small>{group.hint}</small>
</div>
<button
className="btn compact"
type="button"
onClick={() => onToggleGroup(group.permissions, !all)}
>
{all ? "Clear" : "Select all"}
</button>
</div>
<div className="permission-grid">
{group.permissions.map((permission) => (
<label className="permission-item" key={permission} title={permission}>
<input
type="checkbox"
checked={selected.includes(permission)}
onChange={(event) => onToggle(permission, event.target.checked)}
/>
<span className="permission-copy">
<strong>{permissionTitle(permission)}</strong>
<small>{permissionHint(permission)}</small>
</span>
</label>
))}
</div>
</section>
);
})}
</div>
);
}
// OperatorModal creates a new operator, or edits an existing one's access. The
// same shape either way: the only difference is whether a username and password
// are being chosen.
//
// Laid out as head / scrolling body / action bar like every other command modal
// in the panel, so a long permission list scrolls inside the dialog instead of
// pushing its own confirm button off the screen.
function OperatorModal({
title,
available,
existing,
onClose,
onDone
}: {
title: string;
available: string[];
existing?: AdminConsoleUser;
onClose: () => void;
onDone: () => void;
}) {
const [username, setUsername] = useState(existing?.username ?? "");
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState<string[]>(existing?.permissions ?? []);
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
const isEdit = Boolean(existing);
// Only the shape the server insists on: a username it will accept, and a
// password that is actually present. Length is the operator's business.
const incomplete = isEdit
? false
: username.trim().length < 3 || password.trim() === "";
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Operators"}</div>
<h2>{title}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
{!isEdit && (
<div className="operator-identity">
<label className="duration-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="duration-field">
<span>{"Password"}</span>
<input
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label>
</div>
)}
<PermissionPicker
available={available}
selected={permissions}
onToggle={(permission, on) =>
setPermissions((current) =>
on ? [...current, permission] : current.filter((p) => p !== permission)
)
}
onToggleGroup={(group, on) =>
setPermissions((current) =>
on
? [...current, ...group.filter((p) => !current.includes(p))]
: current.filter((p) => !group.includes(p))
)
}
/>
<label className="permission-item standalone">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
<span className="permission-copy">
<strong>{"Account is enabled"}</strong>
<small>{"A disabled operator cannot sign in"}</small>
</span>
</label>
{isEdit && (
<Alert>{"The new access applies from this operator's next request. They stay signed in."}</Alert>
)}
</div>
<div className="modal-actions toolbar">
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
<ActionButton
label={isEdit ? "Save access" : "Create operator"}
path={isEdit ? "/api/actions/set-admin-operator-access" : "/api/actions/create-admin-operator"}
tone="primary"
disabled={incomplete}
icon={isEdit ? <ShieldCheck size={15} /> : <UserPlus size={15} />}
payload={() =>
isEdit
? { id: existing?.id, permissions, enabled }
: { username: username.trim(), password, permissions, enabled }
}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body
);
}
function PasswordModal({
operator,
onClose,
onDone
}: {
operator: AdminConsoleUser;
onClose: () => void;
onDone: () => void;
}) {
const [password, setPassword] = useState("");
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal narrow" role="dialog" aria-modal="true" aria-label={"Set password"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Operators"}</div>
<h2>{`Password for ${operator.username}`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className="duration-field">
<span>{"New password"}</span>
<input
autoFocus
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<Alert>{"Changing the password signs this operator out of any session they already have."}</Alert>
</div>
<div className="modal-actions toolbar">
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
<ActionButton
label={"Set password"}
path={"/api/actions/set-admin-operator-password"}
tone="primary"
disabled={password.trim() === ""}
icon={<KeyRound size={15} />}
payload={() => ({ id: operator.id, password })}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body
);
}

View file

@ -6,6 +6,10 @@ import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
// Deliberately not pre-filled: the built-in operator is a default name, not a
// default identity, and typing it is the difference between choosing to use
// it and drifting into it. The server rejects a blank username either way.
const [username, setUsername] = useState("");
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -17,7 +21,7 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
try {
// The login answer carries the permission set and the CSRF token; api.login
// remembers the token, the session state keeps the rights.
const result = await api.login(secret);
const result = await api.login(secret, username);
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
} catch (err) {
setError(errorMessage(err));
@ -44,7 +48,6 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
</div>
<div className="login-head-actions">
<ThemeSwitch />
<span className="login-chip">{"Local access"}</span>
</div>
</div>
<div className="login-copy">
@ -54,9 +57,23 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span>{"Admin password or token"}</span>
<span>{"Username"}</span>
<input
autoFocus
type="text"
value={username}
autoComplete="username"
spellCheck={false}
autoCapitalize="none"
// A hint, not a value: it names the built-in operator without
// filling the field in, so signing in as it stays a deliberate act.
placeholder={"owpengram"}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
<span>{"Password"}</span>
<input
type="password"
value={secret}
autoComplete="current-password"

View file

@ -16,6 +16,7 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GifCatalogPage } from "./GifCatalogPage";
import { AdminUsersPage } from "./AdminUsersPage";
import { ServerSettingsPage } from "./ServerSettingsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
@ -28,7 +29,7 @@ import {
PermissionGate,
ThirdPartyVerificationHiddenGate,
permissionBotVerificationReview,
permissionServerManage,
permissionServerManage, permissionAdminsManage,
permissionVerificationReview
} from "../permissions";
@ -126,6 +127,13 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/gif-catalog") {
return <GifCatalogPage />;
}
if (route.path === "/admin-users") {
return (
<PermissionGate permission={permissionAdminsManage}>
<AdminUsersPage />
</PermissionGate>
);
}
if (route.path === "/server-settings") {
return (
<PermissionGate permission={permissionServerManage}>