mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
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
369 lines
11 KiB
TypeScript
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>;
|
|
}
|