server admin panel is now supports multiple operators profiles
This commit is contained in:
parent
e48160ac3a
commit
280321b902
25 changed files with 2197 additions and 128 deletions
|
|
@ -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()}`),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
393
cmd/telesrv-admin/web/src/pages/AdminUsersPage.tsx
Normal file
393
cmd/telesrv-admin/web/src/pages/AdminUsersPage.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue