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(