added full access row to operator modal, plus a test for the last-manager guard

The "*" wildcard was never in assignablePermissions, so an operator holding it
(the one the first-run wizard creates) showed every box unticked while having
every right, and there was no way to take it away. Its own row fixes both; the
grid is disabled while it is on, since normalisePermissions collapses "*" plus
anything back to "*".

That made guardManagerRemoval reachable from the UI for the first time, so it
now has an integration test covering the wildcard match and the enabled filter
in its SQL.
This commit is contained in:
onysd 2026-09-11 15:39:08 +03:00
parent c04a8ddc6a
commit 7ad68c3983
4 changed files with 136 additions and 5 deletions

View file

@ -4,7 +4,7 @@ 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 { groupPermissions, permissionAll, permissionHint, permissionTitle } from "../permissions";
import type { AdminConsoleUser, AdminConsoleSystemOperator } from "../types";
// The operator-accounts screen. The table only reports; every change happens in
@ -165,19 +165,42 @@ function PermissionChips({ permissions }: { permissions: string[] }) {
//
// The raw permission string stays as each row's tooltip, so the screen never
// hides what is actually being stored.
//
// "*" gets its own row rather than a box in the grid below, because
// assignablePermissions() deliberately leaves it out of the assignable list
// (cmd/telesrv-admin/security.go) -- without this row an operator holding the
// wildcard, like the one the first-run wizard creates, renders as every box
// unticked while Has() answers true for everything, and there is no way to take
// it away again. While it is on the grid is disabled: normalisePermissions
// collapses "*" plus anything back to just "*", so ticking a box there would be
// a no-op the screen would otherwise show as a change.
function PermissionPicker({
available,
selected,
onToggle,
onToggleGroup
onToggleGroup,
onToggleAll
}: {
available: string[];
selected: string[];
onToggle: (permission: string, on: boolean) => void;
onToggleGroup: (permissions: string[], on: boolean) => void;
onToggleAll: (on: boolean) => void;
}) {
const full = selected.includes(permissionAll);
return (
<div className="permission-groups">
<section className="permission-group">
<div className="permission-grid">
<label className="permission-item" title={permissionAll}>
<input type="checkbox" checked={full} onChange={(event) => onToggleAll(event.target.checked)} />
<span className="permission-copy">
<strong>{"Full access"}</strong>
<small>{"Every right below, including ones added in future updates. Turn off to pick rights individually."}</small>
</span>
</label>
</div>
</section>
{groupPermissions(available).map((group) => {
const all = group.permissions.every((p) => selected.includes(p));
return (
@ -190,6 +213,7 @@ function PermissionPicker({
<button
className="btn compact"
type="button"
disabled={full}
onClick={() => onToggleGroup(group.permissions, !all)}
>
{all ? "Clear" : "Select all"}
@ -200,7 +224,8 @@ function PermissionPicker({
<label className="permission-item" key={permission} title={permission}>
<input
type="checkbox"
checked={selected.includes(permission)}
checked={full || selected.includes(permission)}
disabled={full}
onChange={(event) => onToggle(permission, event.target.checked)}
/>
<span className="permission-copy">
@ -301,6 +326,11 @@ function OperatorModal({
: current.filter((p) => !group.includes(p))
)
}
onToggleAll={(on) =>
setPermissions((current) =>
on ? [permissionAll] : current.filter((p) => p !== permissionAll)
)
}
/>
<label className="permission-item standalone">