Merge remote-tracking branch 'origin/master' into host-pairing

This commit is contained in:
Danielv123 committed 2026-11-01 13:16:11 +01:00
commit 8908079022
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(