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

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

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-C_av3Wxh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0J_zQR.css">
<script type="module" crossorigin src="/assets/index-DwoQ5cLE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8s1nuLP.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,4 +1,5 @@
import type {
AdminConsoleUserList,
AccountDetail,
AccountListResponse,
AccountStatsResponse,
@ -144,16 +145,21 @@ export function errorMessage(error: unknown): string {
export const api = {
session: () => request<AdminSession>("/api/session"),
login: async (secret: string) => {
// The built-in operator is named "owpengram" and is checked against the
// configured TELESRV_ADMIN_UI_PASSWORD / _TOKEN -- the break-glass login
// that still works when the database is unreachable. A blank username is
// rejected: there is no anonymous way in.
login: async (secret: string, username = "") => {
const result = await request<AdminLoginResult>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
body: JSON.stringify({ username, secret })
});
// Stashed here rather than in the caller so no login path can forget it.
rememberCSRFToken(result.csrf_token);
return result;
},
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
adminUsers: () => request<AdminConsoleUserList>("/api/admin-users"),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),

View file

@ -10,6 +10,8 @@ import {
Megaphone,
MessageSquareText,
Settings,
UserCog,
UserRound,
Share2,
ShieldAlert,
ShieldCheck,
@ -21,7 +23,17 @@ import {
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { permissionBotVerificationReview, permissionServerManage, permissionAdminsManage,
permissionAccountsRead,
permissionChannelsRead,
permissionBotsRead,
permissionMessagesRead,
permissionModerationReview,
permissionBroadcastsRead,
permissionStorageRead,
permissionContentRead,
permissionUsernamesRead,
permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AddServerLinkModal } from "./AddServerLinkModal";
@ -84,6 +96,18 @@ export function Shell({
// The verification queue is hidden for a session without verification.review:
// the entry would only lead to a 403 (and the route itself is gated as well).
const canReviewVerification = useCan(permissionVerificationReview);
const canManageAdmins = useCan(permissionAdminsManage);
// Each section entry is hidden without the right to open it: the route is
// gated server-side either way, so showing it would only lead to a 403.
const canReadAccounts = useCan(permissionAccountsRead);
const canReadChannels = useCan(permissionChannelsRead);
const canReadBots = useCan(permissionBotsRead);
const canReadMessages = useCan(permissionMessagesRead);
const canReviewModeration = useCan(permissionModerationReview);
const canReadBroadcasts = useCan(permissionBroadcastsRead);
const canReadStorage = useCan(permissionStorageRead);
const canReadContent = useCan(permissionContentRead);
const canReadUsernames = useCan(permissionUsernamesRead);
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
@ -217,22 +241,42 @@ export function Shell({
<div className="sidebar-label">{"Navigation"}</div>
<nav className="nav-list" aria-label={"Primary navigation"}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
{canReadAccounts && (
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
)}
{canReadChannels && (
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
)}
{canReadBots && (
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
)}
{canReviewModeration && (
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
)}
{canReadBroadcasts && (
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
)}
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)}
{canReviewBotVerification && !thirdPartyVerificationHidden && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
{canReadUsernames && (
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
)}
{canReadStorage && (
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
)}
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
@ -265,6 +309,9 @@ export function Shell({
</div>
)}
</div>
{canManageAdmins && (
<NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
)}
{canManageServer && (
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
)}
@ -313,7 +360,7 @@ export function Shell({
</div>
<div className="topbar-actions">
<ThemeSwitch />
<span className="actor-pill">{`Actor: ${actor}`}</span>
<span className="actor-pill"><UserRound size={14} /> {actor}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
<LogOut size={16} /> {"Log out"}
</button>

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}>

View file

@ -17,6 +17,22 @@ export const permissionBotVerificationManage = "botverification.manage";
// Server Settings: identity, .env, restart/update. One right, not
// review/manage -- see the constant's doc comment in security.go.
export const permissionServerManage = "server.manage";
// Operator accounts. The one right that can hand out every other right, so it
// is never implied by anything else -- see the constant's doc comment in
// security.go.
export const permissionAdminsManage = "admins.manage";
// Section rights, in read/manage pairs following the sidebar -- see the const
// block in security.go, which these must match exactly.
export const permissionAccountsRead = "accounts.read";
export const permissionChannelsRead = "channels.read";
export const permissionBotsRead = "bots.read";
export const permissionMessagesRead = "messages.read";
export const permissionModerationReview = "moderation.review";
export const permissionBroadcastsRead = "broadcasts.read";
export const permissionStorageRead = "storage.read";
export const permissionContentRead = "content.read";
export const permissionUsernamesRead = "usernames.read";
export const permissionDashboardRead = "dashboard.read";
// GET /api/session is read once at boot; the panel keeps the answer here so a
// section the session may not use is hidden instead of rendered into a 403. This
@ -117,3 +133,117 @@ export function ThirdPartyVerificationHiddenGate({ children }: { children: React
</PageFrame>
);
}
// Human-readable names for the permission strings. The raw value is what the
// backend stores and checks, but "content.manage" is a machine's word for it --
// an operator ticking boxes should read what the right actually lets someone do.
//
// Anything missing from this map falls back to the raw string rather than being
// hidden, so a right added on the server still appears (just untranslated)
// instead of silently vanishing from the editor.
const permissionLabels: Record<string, { title: string; hint: string }> = {
"accounts.read": { title: "View accounts", hint: "Browse users, their profiles and sessions" },
"accounts.manage": { title: "Edit accounts", hint: "Change profiles, usernames, freeze and revoke sessions" },
"channels.read": { title: "View groups and channels", hint: "Browse supergroups and channels" },
"channels.manage": { title: "Edit groups and channels", hint: "Change settings, usernames and avatars" },
"bots.read": { title: "View bots", hint: "Browse the bot list and their details" },
"bots.manage": { title: "Create and delete bots", hint: "Add new bots and remove existing ones" },
"bots.token.read": { title: "Reveal bot tokens", hint: "Export a bot's live credential" },
"messages.read": { title: "View messages", hint: "Read private and group message history" },
"messages.manage": { title: "Delete messages", hint: "Remove messages and clear history" },
"moderation.review": { title: "Handle reports", hint: "Work the moderation queue and decide cases" },
"broadcasts.read": { title: "View broadcasts", hint: "See past and scheduled broadcasts" },
"broadcasts.send": { title: "Send broadcasts", hint: "Deliver a message to many users at once" },
"content.read": { title: "View stickers, emoji and GIFs", hint: "Browse the packs and the GIF catalogue" },
"content.manage": { title: "Edit stickers, emoji and GIFs", hint: "Create, rename and remove packs and catalogue entries" },
"usernames.read": { title: "View NFT usernames", hint: "Browse collectible usernames" },
"usernames.manage": { title: "Manage NFT usernames", hint: "Mint, transfer and revoke collectible usernames" },
"storage.read": { title: "View storage", hint: "See media usage per account" },
"storage.manage": { title: "Purge storage", hint: "Manually delete stored media" },
"dashboard.read": { title: "View the dashboard", hint: "See the overview counters and server health" },
"premium.manage": { title: "Manage Premium", hint: "Grant, revoke and refund Premium" },
"verification.review": { title: "Verify accounts", hint: "Work the verification queue and grant badges" },
"verification.revoke": { title: "Remove verification", hint: "Take a granted badge away (needs the right above too)" },
"botverification.review": { title: "Handle third-party marks", hint: "Work the third-party verification queue" },
"botverification.manage": { title: "Appoint verifiers", hint: "Grant verifier status and curate mark icons" },
"server.manage": { title: "Server settings", hint: "Identity, .env editing, restart and update" },
"admins.manage": { title: "Manage operators", hint: "Create operators and decide what everyone can do" },
"*": { title: "Full access", hint: "Every right, including future ones" }
};
export function permissionTitle(permission: string): string {
return permissionLabels[permission]?.title ?? permission;
}
export function permissionHint(permission: string): string {
return permissionLabels[permission]?.hint ?? "";
}
// Rights grouped by the part of the console they govern, so the editor reads as
// a few short decisions instead of one wall of twenty-six checkboxes.
//
// The order is roughly "everyday work first, keys to the building last": an
// operator scanning down the list meets the routine rights before the ones that
// can undo the deployment.
export const permissionGroups: { title: string; hint: string; permissions: string[] }[] = [
{
title: "People and chats",
hint: "Users, groups and their message history",
permissions: ["accounts.read", "accounts.manage", "channels.read", "channels.manage", "messages.read", "messages.manage"]
},
{
title: "Moderation and verification",
hint: "Reports, badges and third-party marks",
permissions: ["moderation.review", "verification.review", "verification.revoke", "botverification.review", "botverification.manage"]
},
{
title: "Content",
hint: "Sticker packs, emoji, GIFs and collectible usernames",
permissions: ["content.read", "content.manage", "usernames.read", "usernames.manage"]
},
{
title: "Bots",
hint: "The bot roster and its credentials",
permissions: ["bots.read", "bots.manage", "bots.token.read"]
},
{
title: "Broadcasting",
hint: "Messages sent to many users at once",
permissions: ["broadcasts.read", "broadcasts.send"]
},
{
title: "Storage and overview",
hint: "Media usage and the dashboard",
permissions: ["storage.read", "storage.manage", "dashboard.read"]
},
{
title: "Billing",
hint: "Premium grants and refunds",
permissions: ["premium.manage"]
},
{
title: "The console itself",
hint: "The two rights that can change the deployment or hand out every other right",
permissions: ["server.manage", "admins.manage"]
}
];
// groupPermissions arranges the server's list into the groups above. Anything
// the server offers that no group claims is collected at the end rather than
// dropped, so a right added on the backend still appears here without this file
// having to be edited first.
export function groupPermissions(available: string[]): { title: string; hint: string; permissions: string[] }[] {
const remaining = new Set(available);
const out: { title: string; hint: string; permissions: string[] }[] = [];
for (const group of permissionGroups) {
const present = group.permissions.filter((p) => remaining.has(p));
present.forEach((p) => remaining.delete(p));
if (present.length > 0) {
out.push({ title: group.title, hint: group.hint, permissions: present });
}
}
if (remaining.size > 0) {
out.push({ title: "Other", hint: "Rights this console version does not have a group for", permissions: [...remaining] });
}
return out;
}

View file

@ -482,8 +482,11 @@ a {
.actor-pill {
display: inline-flex;
min-height: 30px;
/* Icon and name read as one label rather than two adjacent things. */
gap: 6px;
align-items: center;
padding: 0 10px;
padding: 0 12px;
font-weight: 600;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);

View file

@ -936,3 +936,169 @@ textarea:focus {
}
}
/* Operator accounts (AdminUsersPage). The permission picker is a checkbox grid
rather than a role dropdown: the backend stores a permission set, so the
screen shows exactly that set instead of a friendlier abstraction that could
drift from what the routes enforce.
Selectors carry .form-stack because these labels live inside one, and
".form-stack label { display: grid }" would otherwise out-specify a bare
.permission-item and stack the box above its own text. */
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin: 2px 0 8px;
}
.form-stack .permission-item,
.permission-item {
display: flex;
align-items: center;
gap: 8px;
width: auto;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-subtle);
cursor: pointer;
font-size: 12px;
transition: border-color 140ms ease;
}
.permission-item:hover {
border-color: var(--brand-tint-border);
}
/* Explicit box size: the generic "input" rule gives fields a text-input's
padding and .form-stack stretches them to 100%, neither of which suits a
checkbox. */
.form-stack .permission-item input[type="checkbox"],
.permission-item input[type="checkbox"] {
width: 15px;
height: 15px;
flex: 0 0 auto;
margin: 0;
padding: 0;
border-radius: 4px;
accent-color: var(--brand);
cursor: pointer;
}
/* Title over hint. The checkbox stays vertically centred against the pair
rather than against the first line, so a two-line entry does not look
top-heavy. */
.permission-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.permission-copy strong {
overflow: hidden;
color: var(--text);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-copy small {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The standalone Enabled toggle is one control, not a grid cell, so it sits at
its natural width instead of stretching across the row. */
.permission-item.standalone {
justify-self: start;
width: max-content;
}
/* A granted permission, listed in the table. It carries the human name now, so
it is set in the UI font -- the raw "content.manage" string stays available
as the chip's tooltip for anyone who needs to match it against the .env. */
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip {
display: inline-block;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-strong);
color: var(--text-soft);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.pill {
display: inline-block;
padding: 2px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-strong);
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
}
.pill.good {
border-color: var(--good-border);
color: var(--good);
}
/* Grouped permission editor. Each group is a labelled block so the twenty-odd
rights read as a few short decisions rather than one undifferentiated run. */
.permission-groups {
display: grid;
gap: 14px;
}
.permission-group {
display: grid;
gap: 8px;
}
.permission-group-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.permission-group-head strong {
display: block;
color: var(--heading);
font-size: 13px;
}
.permission-group-head small {
display: block;
margin-top: 1px;
color: var(--muted);
font-size: 11px;
}
/* Username and password sit side by side above the rights, so the identity
fields do not read as the first permission group. */
.operator-identity {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
/* The password dialog has one field; the command modal's default width would
leave it stranded in the middle of a mostly empty sheet. */
.modal.narrow {
width: min(460px, 100%);
}

View file

@ -907,3 +907,33 @@ export type DockerService = {
state: string;
health: string;
};
// One admin console operator. Mirrors AdminConsoleUser in adminusers.go; the
// password hash deliberately has no representation here.
export type AdminConsoleUser = {
id: number;
username: string;
permissions: string[];
enabled: boolean;
token_epoch: number;
created_at: string;
updated_at: string;
last_login_at?: string | null;
};
// The built-in operator backed by TELESRV_ADMIN_UI_PASSWORD / _TOKEN. It has no
// database row, so it carries no id and cannot be edited from the panel.
export type AdminConsoleSystemOperator = {
username: string;
permissions: string[];
enabled: boolean;
system: true;
};
export type AdminConsoleUserList = {
system?: AdminConsoleSystemOperator;
rows: AdminConsoleUser[];
// The rights the server is willing to assign, so the editor cannot drift
// from what the routes actually enforce.
available_permissions: string[];
};