From 78f1e8a99305536f00913df044a397766996c276 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:52:59 +0000 Subject: [PATCH] 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 --- .../web_ui/src/components/RoleViewPage.tsx | 107 +++++++++++------- .../web_ui/src/components/UserViewPage.tsx | 17 ++- test/messages/controller.test.js | 14 +++ 3 files changed, 93 insertions(+), 45 deletions(-) diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index 41d7eb26..5e61df85 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -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; permissionState: Record; - setPermissionState: React.Dispatch>>; - 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)} /> ))} @@ -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>({}); - const [baselineState, setBaselineState] = useState>({}); + // Local edits on top of the live role + const [nameEdit, setNameEdit] = useState(); + const [descriptionEdit, setDescriptionEdit] = useState(); + const [permissionEdits, setPermissionEdits] = useState>({}); const [search, setSearch] = useState(""); + const loadedRoleId = useRef(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 -

Role with id {roleId} was not found on the controller.

+

Role with id {roleId} { + loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller" + }.

; } + 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() {
- setName(e.target.value)} /> + setNameEdit(e.target.value)} />
- setDescription(e.target.value)} /> + setDescriptionEdit(e.target.value)} />
@@ -363,7 +384,7 @@ export default function RoleViewPage() { permissions={permissions} permissionState={permissionState} baselineState={baselineState} - setPermissionState={setPermissionState} + setPermissions={setPermissions} canUpdate={canUpdate} /> ))} diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 4c1b1048..02e9733b 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -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() { ; } - // TODO: Update displayed roles when user roles change and have not been edited locally let roleSelector = 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(