Files
factorio-clusterio/packages/web_ui/src/components/UserViewPage.tsx
T
Danielv123 2e1710d3a9 Show the same page for missing, deleted and unloadable resources
Every view page handled a resource that can't be shown its own way, and
users without the subscribe permission were left on a spinner forever.

Adds useResourceState and a ResourceState component, used by the host,
instance, mod pack, icon reference, user, role and plugin pages and
exported for plugin pages. The single item hooks now also return the
subscription error.

Resolves #1165
2026-11-01 17:40:22 +01:00

369 lines
11 KiB
TypeScript

import React, { useEffect, useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button, Col, Descriptions, Form, Input, Popconfirm,
Row, Table, Tag, Select, Space, Switch,
} from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import useTableQueryState from "../util/useTableQueryState";
import { useAccount } from "../model/account";
import { useInstances } from "../model/instance";
import ControlContext from "./ControlContext";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
import ResourceNotes from "./ResourceNotes";
import ResourceState from "./ResourceState";
import useRowNavigation from "../util/useRowNavigation";
import useResourceState from "../util/useResourceState";
import Link from "./Link";
import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUser } from "../model/user";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
// The user page has a lot of optional elements, that does not make it
// praticularly complicated.
// eslint-disable-next-line complexity
export default function UserViewPage() {
let params = useParams();
let userName = params.name as string;
let navigate = useNavigate();
const rowNav = useRowNavigation();
let account = useAccount();
let control = useContext(ControlContext);
let [instances] = useInstances();
const [user, synced, error] = useUser(userName);
const state = useResourceState(user, synced, error, userName);
let [roles, rolesSynced] = useRoles();
let [form] = Form.useForm();
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
namespace: "stats", defaultSortKey: "instance",
});
let [rolesDirty, setRolesDirty] = useState<boolean>(false);
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
let [rolesError, setRolesError] = useState<string|undefined>();
useEffect(() => {
if (
account.hasPermission("core.user.set_banned")
&& !banReasonDirty
&& user
&& user.banReason !== form.getFieldValue("banReason")
) {
form.setFieldsValue({ "banReason": user.banReason });
}
}, [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) {
return <ResourceState state={state} type="User" id={userName} nav={nav} listPath="/users" error={error} />;
}
let roleSelector = <Form.Item
noStyle
name="roles"
initialValue={[...user.roleIds]}
rules={[
{ validator: () => (rolesError ? Promise.reject(rolesError) : Promise.resolve()) },
]}
>
<Select
onChange={() => setRolesDirty(true)}
loading={!roles}
mode="multiple"
optionFilterProp="name"
>
{[...roles.values()].map(r => <Select.Option
key={r.id}
value={r.id}
name={r.name}
>{r.name}</Select.Option>)}
</Select>
</Form.Item>;
function instanceName(id: number) {
return instances.get(id)?.name ?? String(id);
}
return <PageLayout nav={nav}>
<PageHeader
title={<Space>
{userName}
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
</Space>}
extra={<Space wrap>
{account.hasPermission("core.user.revoke_token") && (
account.name === userName || account.hasPermission("core.user.revoke_other_token")
) && <Button
danger
onClick={() => {
control.send(
new lib.UserRevokeTokenRequest(userName)
).then(() => {
notify("User token revoked");
}, err => {
if (err instanceof lib.SessionLost && userName === account.name) {
// Got kicked out after revoking our own token
notify("User token revoked");
return;
}
throw err;
}).catch(notifyErrorHandler("Error revoking token"));
}}
>Revoke token</Button>}
{account.hasPermission("core.user.delete") && <Popconfirm
title={<>
Delete user account and all data associated with it?
{(user.isBanned && <><br/>This will remove the user ban!</>)}
</>}
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.UserDeleteRequest(userName)
).then(() => {
navigate("/users");
}, err => {
if (err instanceof lib.SessionLost && userName === account.name) {
// Got kicked out after deleting our own account
navigate("/users");
return;
}
throw err;
}).catch(notifyErrorHandler("Error deleting user"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
</Popconfirm>}
</Space>}
/>
<Form
form={form}
labelCol={{
sm: { span: 6 },
md: { span: 6 },
lg: { span: 4 },
xl: { span: 3 },
xxl: { span: 2 },
}}
wrapperCol={{
sm: { span: 18 },
md: { span: 18 },
lg: { span: 20 },
xl: { span: 21 },
xxl: { span: 22 },
}}
>
<Form.Item
label="Roles"
>
<Row gutter={[8, 8]}>
<Col flex="auto">
{account.hasPermission("core.user.update_roles")
? roleSelector
: [...user.roleIds!].map(id => <Tag key={id}>{
roles?.get(id)?.name ?? id
}</Tag>)
}
</Col>
{account.hasPermission("core.user.update_roles") && <Col flex="0 0 auto">
<Button
type={"primary"}
disabled={!rolesDirty}
loading={applyingRoles}
onClick={() => {
let newRoles = form.getFieldValue("roles");
setApplyingRoles(true);
control.send(
new lib.UserUpdateRolesRequest(userName, newRoles)
).then(() => {
setRolesDirty(false);
setRolesError(undefined);
}).catch(err => {
setRolesError(err.message);
return form.validateFields();
}).finally(() => {
setApplyingRoles(false);
});
}}
>Apply</Button>
</Col>}
</Row>
</Form.Item>
{account.hasPermission("core.user.set_admin") && <Form.Item label="In-game Admin">
<Switch
checked={user.isAdmin}
onClick={() => {
control.send(
new lib.UserSetAdminRequest(userName, false, !user.isAdmin)
).catch(notifyErrorHandler("Error toggling user admin status"));
}}
/>
</Form.Item>}
{account.hasPermission("core.user.set_whitelisted") && <Form.Item label="Whitelisted">
<Switch
checked={user.isWhitelisted}
onClick={() => {
control.send(
new lib.UserSetWhitelistedRequest(userName, false, !user.isWhitelisted)
).catch(notifyErrorHandler("Error toggling user whitelisted status"));
}}
/>
</Form.Item>}
{account.hasPermission("core.user.set_banned")
? <Form.Item label="Ban reason">
<Row gutter={[8, 8]} justify={"end"}>
<Col flex="auto" style={{ minWidth: "20em" }}>
<Form.Item noStyle name="banReason" initialValue={user.banReason}>
<Input
onChange={e => {
setBanReasonDirty(e.target.value !== user.banReason);
}}
/>
</Form.Item>
</Col>
<Col flex="0 0 auto">
{user.isBanned
? <Space>
<Button
type={banReasonDirty ? "primary" : "default"}
onClick={() => {
control.send(
new lib.UserSetBannedRequest(
userName, false, true, form.getFieldValue("banReason")
)
).then(() => {
setBanReasonDirty(false);
}).catch(notifyErrorHandler("Error updating ban"));
}}
>Update</Button>
<Button
onClick={() => {
control.send(
new lib.UserSetBannedRequest(userName, false, false, "")
).catch(notifyErrorHandler("Error unbanning user"));
}}
>Unban</Button>
</Space>
: <Button
type={banReasonDirty ? "primary" : "default"}
onClick={() => {
control.send(
new lib.UserSetBannedRequest(
userName, false, true, form.getFieldValue("banReason")
)
).then(() => {
setBanReasonDirty(false);
}).catch(notifyErrorHandler("Error banning user"));
}}
>Ban</Button>
}
</Col>
</Row>
</Form.Item>
: user.isBanned
&& <Form.Item label="Ban reason">{user.banReason}</Form.Item>
}
</Form>
<ResourceNotes resourceType="user" resourceId={user.id} />
<SectionHeader title="Player stats" />
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
<Descriptions.Item label="Total online time">
{formatDuration(user.playerStats?.onlineTimeMs ?? 0)}
</Descriptions.Item>
<Descriptions.Item label="Total join count">
{user.playerStats?.joinCount ?? 0}
</Descriptions.Item>
<Descriptions.Item label="First seen">
{formatFirstSeen(user) || " "}
</Descriptions.Item>
<Descriptions.Item label="Last seen">
{formatLastSeen(user) || " "}
</Descriptions.Item>
</Descriptions>
<SectionHeader title="Instance stats" />
<Table
size="small"
columns={[
{
title: "Instance",
key: "instance",
className: "table-link-cell",
render: (_, [id]) => <Link to={`/instances/${id}/view`} style={{ color: "inherit" }}>
{instanceName(id)}
</Link>,
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
sortOrder: statsTable.sortOrder("instance"),
},
{
title: "Online time",
key: "onlineTime",
render: (_, [, stats]) => formatDuration(stats.onlineTimeMs || 0),
sorter: (a, b) => (a[1].onlineTimeMs || 0) - (b[1].onlineTimeMs || 0),
sortOrder: statsTable.sortOrder("onlineTime"),
},
{
title: "Join count",
key: "joinCoint",
render: (_, [, stats]) => stats.joinCount || 0,
sorter: (a, b) => (a[1].joinCount || 0) - (b[1].joinCount || 0),
sortOrder: statsTable.sortOrder("joinCoint"),
responsive: ["sm"],
},
{
title: "First seen",
key: "firstSeen",
render: (_, [id]) => formatFirstSeen(user, id),
sorter: (a, b) => sortFirstSeen(user, user, a[0], b[0]),
sortOrder: statsTable.sortOrder("firstSeen"),
},
{
title: "Last seen",
key: "lastSeen",
render: (_, [id]) => formatLastSeen(user, id),
sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]),
sortOrder: statsTable.sortOrder("lastSeen"),
},
]}
dataSource={[...(user.instanceStats || []).entries()]}
pagination={statsTable.pagination}
onChange={statsTable.onChange}
rowKey={([id]) => id}
onRow={([id]) => rowNav(`/instances/${id}/view`)}
/>
<PluginExtra component="UserViewPage" user={user} />
</PageLayout>;
}