Live update roles on the user and role view pages

The user page set the role selector once and never updated it. The role
page reset its whole form on every role update, wiping unsaved edits.

Both pages now follow the live data and keep local edits on top. The role
page stores only the edited fields and drops them once they match the role
or when the viewer loses core.role.update. The user page syncs the
selector while it is not dirty and drops deleted roles from a dirty
selection. A role deleted while open now says so instead of "not found".

Fixes #775, fixes #776

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-29 22:52:59 +00:00
1 parent fa2883d74e
commit 78f1e8a993
3 files changed
+93 -45

No files matched your search

+64 -43
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useState, useMemo } from "react";
import React, { useEffect, useContext, useState, useMemo, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd";
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
@@ -88,7 +88,7 @@ function PermissionItem({
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
function PermissionGroup({
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
groupName, permissions, canUpdate, baselineState, permissionState, setPermissions,
}: {
groupName: string;
permissions: lib.Permission[];
@@ -96,17 +96,10 @@ function PermissionGroup({
baselineState: Record<string, boolean>;
permissionState: Record<string, boolean>;
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setEdited?: (value: boolean) => void;
setPermissions: (names: string[], value: boolean) => void;
}) {
function setGroup(value: boolean) {
setPermissionState((prev: any) => {
const next = { ...prev };
for (let p of permissions) {
next[p.name] = value;
}
return next;
});
setPermissions(permissions.map(p => p.name), value);
}
return (
@@ -129,7 +122,7 @@ function PermissionGroup({
checked={permissionState[p.name]}
baseline={baselineState[p.name]}
disabled={!canUpdate}
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
onChange={v => setPermissions([p.name], v)}
/>
))}
</div>
@@ -171,11 +164,12 @@ export default function RoleViewPage() {
const control = useContext(ControlContext);
const [role, synced] = useRole(roleId);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
// Local edits on top of the live role
const [nameEdit, setNameEdit] = useState<string>();
const [descriptionEdit, setDescriptionEdit] = useState<string>();
const [permissionEdits, setPermissionEdits] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
const loadedRoleId = useRef<number>(undefined);
// Hidden below lg, where the site sider leaves too little room; undefined until measured
const screens = Grid.useBreakpoint();
const showGroupList = screens.lg !== false;
@@ -183,20 +177,35 @@ export default function RoleViewPage() {
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
const canUpdate = Boolean(account.hasPermission("core.role.update"));
const baselineState = useMemo(() => Object.fromEntries(
allPermissions.map(p => [p.name, Boolean(role?.permissions.has(p.name))])
), [role, allPermissions]);
// Drop edits that now match the role, and all edits if we can no longer apply them
useEffect(() => {
if (!role) {
if (!role || !canUpdate) {
setNameEdit(undefined);
setDescriptionEdit(undefined);
setPermissionEdits(prev => (Object.keys(prev).length ? {} : prev));
return;
}
setNameEdit(prev => (prev === role.name ? undefined : prev));
setDescriptionEdit(prev => (prev === role.description ? undefined : prev));
setPermissionEdits(prev => {
const next = Object.fromEntries(Object.entries(prev).filter(([k, v]) => v !== baselineState[k]));
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
});
}, [role, canUpdate, baselineState]);
const base = Object.fromEntries(
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
);
useEffect(() => {
setNameEdit(undefined);
setDescriptionEdit(undefined);
setPermissionEdits({});
}, [roleId]);
setName(role.name);
setDescription(role.description);
setPermissionState(base);
setBaselineState(base);
}, [role, allPermissions]);
if (role) {
loadedRoleId.current = roleId;
}
// If no role then display either the loading screen or the not found screen
if (!role) {
@@ -211,10 +220,16 @@ export default function RoleViewPage() {
return <PageLayout nav={nav}>
<PageHeader title="Role not found" />
<p>Role with id {roleId} was not found on the controller.</p>
<p>Role with id {roleId} {
loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller"
}.</p>
</PageLayout>;
}
const name = nameEdit ?? role.name;
const description = descriptionEdit ?? role.description;
const permissionState = { ...baselineState, ...permissionEdits };
// Apply the filter by searching all permission fields
const filtered = allPermissions.filter(p => {
const q = search.toLowerCase();
@@ -243,27 +258,33 @@ export default function RoleViewPage() {
const grantedGroups = new Set(allPermissions.filter(p => permissionState[p.name]).map(p => getGroupName(p.name)));
// Check if any changes have been made to the permissions or role metadata
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
const permissionChanged = Object.keys(permissionEdits).some(k => permissionEdits[k] !== baselineState[k]);
const metaChanged = name !== role.name || description !== role.description;
const edited = permissionChanged || metaChanged;
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
.then(() => {
setBaselineState(permissionState);
})
.catch(notifyErrorHandler("Error applying changes"));
function setPermissions(names: string[], value: boolean) {
setPermissionEdits(prev => {
const next = { ...prev };
for (let permissionName of names) {
next[permissionName] = value;
}
return next;
});
}
/** Revert any pending changes */
function revertChanges() {
setPermissionState(baselineState);
if (role) {
setName(role.name);
setDescription(role.description);
}
setNameEdit(undefined);
setDescriptionEdit(undefined);
setPermissionEdits({});
}
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name, description, newPermissions))
.then(revertChanges)
.catch(notifyErrorHandler("Error applying changes"));
}
return (
@@ -320,12 +341,12 @@ export default function RoleViewPage() {
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
<Input value={name} disabled={!canUpdate} onChange={e => setNameEdit(e.target.value)} />
</div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
<Input value={description} disabled={!canUpdate} onChange={e => setDescriptionEdit(e.target.value)} />
</div>
<ResourceNotes resourceType="role" resourceId={roleId} />
@@ -363,7 +384,7 @@ export default function RoleViewPage() {
permissions={permissions}
permissionState={permissionState}
baselineState={baselineState}
setPermissionState={setPermissionState}
setPermissions={setPermissions}
canUpdate={canUpdate}
/>
))}
@@ -41,7 +41,7 @@ export default function UserViewPage() {
let control = useContext(ControlContext);
let [instances] = useInstances();
const [user, synced] = useUser(userName);
let [roles] = useRoles();
let [roles, rolesSynced] = useRoles();
let [form] = Form.useForm();
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
namespace: "stats", defaultSortKey: "instance",
@@ -62,6 +62,20 @@ export default function UserViewPage() {
}
}, [account, form, user, banReasonDirty]);
useEffect(() => {
if (!user || !account.hasPermission("core.user.update_roles")) {
return;
}
const current: number[] = form.getFieldValue("roles") ?? [];
// Keep local edits, but drop roles deleted since
const next = rolesDirty
? current.filter(id => !rolesSynced || roles.has(id))
: [...user.roleIds];
if (next.length !== current.length || next.some((id, i) => id !== current[i])) {
form.setFieldsValue({ "roles": next });
}
}, [account, form, user, roles, rolesSynced, rolesDirty]);
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (!user) {
if (!synced) {
@@ -77,7 +91,6 @@ export default function UserViewPage() {
</PageLayout>;
}
// TODO: Update displayed roles when user roles change and have not been edited locally
let roleSelector = <Form.Item
noStyle
name="roles"
+14
View File
@@ -52,6 +52,20 @@ describe("messages/controller", function() {
});
});
describe("RoleDeleteRequest", function() {
it("removes the role from users and sends a user update", async function() {
const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", []));
const user = controller.users.getOrCreateUser("player");
user.addRole(id);
const updated = [];
controller.users.records.on("update", users => updated.push(...users));
await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id));
assert.equal(controller.roles.has(id), false);
assert.equal(user.roleIds.has(id), false);
assert(updated.some(u => u.name === "player" && !u.roleIds.has(id)), "no user update sent");
});
});
describe("ControllerConfigGetRequest", function() {
it("runs", async function() {
const config = await controlConnection.handleControllerConfigGetRequest(