mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
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:
1 parent
fa2883d74e
commit
78f1e8a993
3 files changed
+93
-45
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user