Add dedicated bot, workspace and user settings pages

This commit is contained in:
2026-10-19 13:17:17 +01:00
parent 731ea73486
commit 7667f2bc2f
74 changed files with 2183 additions and 358 deletions
@@ -0,0 +1,142 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import revokeUser from "../../(functions)/revoke-user";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Revoking or deleting a user, where the actor may. Never yourself, and
* never the super admin.
*/
export default function UserDangerGroup({
person,
actor_user_id,
actor_user_types,
onUpdated,
}: Props) {
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const name = person.first_name || "this user";
const user_types = parseUserTypes({ user_types: person.user_types });
const is_self = person.id == actor_user_id;
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_delete =
!is_self &&
canRevokeUser({ user_types: actor_user_types, target_user_types });
const can_revoke = can_delete && !user_types.includes("revoked");
const revoke = async () => {
if (
busy ||
!window.confirm(
`Revoke ${name}? They can't sign in until Revoked is removed from their user types. Their crew and files are kept.`,
)
)
return;
setBusy(true);
setStatus(undefined);
const res = await revokeUser({ person });
setBusy(false);
if (!res.success || !res.singleRes?.id) {
setStatus({ error: true, msg: res.msg || "Couldn't revoke them." });
return;
}
onUpdated(res.singleRes);
setStatus({ success: true, msg: `${name} can't sign in now.` });
};
const remove = async () => {
if (
busy ||
!window.confirm(
`Delete ${name} with their bots, workspaces and computers? Their computers' files stay on disk.`,
)
)
return;
setBusy(true);
setStatus(undefined);
const res = await postAdminApi({
path: "users/delete-user",
body: { user_id: person.id },
});
if (res.success) {
window.location.href = "/admin/settings/users";
return;
}
setBusy(false);
setStatus({ error: true, msg: res.msg || "Couldn't delete them." });
};
return (
<SettingsGroup
id="danger"
title="Danger zone"
description="Revoking keeps everything they had; deleting takes it with them."
>
{is_self ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
This is you. You can't revoke or delete yourself here.
</Span>
) : !can_delete ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
Nothing can be done here.
</Span>
) : (
<Row className="w-full gap-2! flex-wrap items-center">
{can_revoke ? (
<Button
title={`Revoke ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={busy}
onClick={revoke}
>
Revoke
</Button>
) : null}
<Button
title={`Delete ${name}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={busy}
onClick={remove}
>
Delete user
</Button>
</Row>
)}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,37 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
onSaved: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
};
/**
* The user's profile, photo, user types, access and password, saved
* together. The same form as adding one, embedded on the page.
*/
export default function UserEditGroup({ person, onSaved }: Props) {
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
return (
<SettingsGroup
id="edit"
title="Edit"
description="Their photo, name, contact details, user types, what they reach and a new password."
>
<UserForm
key={`${person.id}-${person.updated_at || ""}`}
person={person}
embedded
onSaved={({ person: saved, msg }) => {
onSaved(saved);
setStatus({ success: true, msg });
}}
/>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,98 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import UserAvatar from "@/src/components/general/user-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseIdList from "@/src/utils/parse-id-list";
import parseUserTypes from "@/src/utils/parse-user-types";
import SettingsGroup from "../../../(partials)/settings-group";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
/**
* The session user, to say "You".
*/
actor_user_id?: number | string;
/**
* Workers and workspaces a member reaches.
*/
crew_bots: BUN_SQLITE_COBBLE_BOTS[];
crew_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
/**
* A user at a glance: their photo, name, contact details, user types and
* what they reach.
*/
export default function UserOverviewGroup({
person,
actor_user_id,
crew_bots,
crew_workspaces,
}: Props) {
const name =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
const user_types = parseUserTypes({ user_types: person.user_types });
const is_self = person.id == actor_user_id;
const reaches_everything =
user_types.includes("super_admin") || user_types.includes("admin");
const bot_ids = parseIdList({ ids: person.bot_ids });
const workspace_ids = parseIdList({ ids: person.workspace_ids });
const bot_names = crew_bots
.filter((bot) => bot_ids.includes(Number(bot.id)))
.map((bot) => bot.name || `Bot ${bot.id}`);
const workspace_names = crew_workspaces
.filter((workspace) => workspace_ids.includes(Number(workspace.id)))
.map((workspace) => workspace.name || `Workspace ${workspace.id}`);
const access = reaches_everything
? "Every worker and workspace."
: [
bot_names.length
? `Bots: ${bot_names.join(", ")}`
: "No bots.",
workspace_names.length
? `Workspaces: ${workspace_names.join(", ")}`
: "No workspaces.",
].join(" ");
return (
<SettingsGroup
id="overview"
title="Overview"
description="Who this is, and what they can reach."
>
<Row className="w-full gap-4! flex-nowrap items-start">
<UserAvatar person={person} size="lg" />
<Stack className="grow min-w-0 gap-1!">
<Row className="gap-2! flex-nowrap items-center">
<Span className="text-sm font-bold leading-5 text-ink truncate">
{name}
</Span>
{is_self ? (
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink-secondary">
You
</Span>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
{[person.email, person.username].filter(Boolean).join(" · ")}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{UserTypes.filter((type) =>
user_types.includes(type.value),
)
.map((type) => type.title)
.join(", ") || "No user types"}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{access}
</Span>
</Stack>
</Row>
</SettingsGroup>
);
}
@@ -0,0 +1,57 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
import SettingsShell from "../../../(partials)/settings-shell";
import UserDangerGroup from "../(partials)/user-danger-group";
import UserEditGroup from "../(partials)/user-edit-group";
import UserOverviewGroup from "../(partials)/user-overview-group";
/**
* One user's settings: overview, the form that edits them, and revoking
* or deleting them where the actor may.
*/
export default function UserSettingsSection() {
const { pageProps } = useContext(AppContext);
const [person, setPerson] = useState<
BUN_SQLITE_COBBLE_USERS_JOIN | undefined
>(pageProps?.target_person || undefined);
if (!person?.id) return null;
const name =
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
person.email ||
`User ${person.id}`;
const user_types = parseUserTypes({ user_types: person.user_types });
const types_title = UserTypes.filter((type) =>
user_types.includes(type.value),
)
.map((type) => type.title)
.join(", ");
return (
<SettingsShell
title={name}
description={
types_title || person.email || "A user on this install."
}
breadcrumb_title={name}
>
<UserOverviewGroup
person={person}
actor_user_id={pageProps?.user?.id}
crew_bots={pageProps?.crew_bots || []}
crew_workspaces={pageProps?.crew_workspaces || []}
/>
<UserEditGroup person={person} onSaved={setPerson} />
<UserDangerGroup
person={person}
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
onUpdated={setPerson}
/>
</SettingsShell>
);
}
@@ -0,0 +1,42 @@
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio";
import canListUsers from "@/src/utils/rbac/can-list-users";
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
/**
* One user's settings, for those who may list users (`canListUsers`):
* their profile, user types, access and danger zone.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props) return settings;
const { user_types } = await userAuth({ req: params.req });
if (!canListUsers({ user_types })) {
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
}
const query: PageQueryObject = params.query;
const target_person = (
await grabUsers({ user_id: String(query.user_id) })
).singleRes;
if (!target_person?.id) {
return { redirect: { destination: "/admin/settings/users", permanent: false } };
}
return {
props: {
...settings.props,
target_person: sanitizeUserBio({ user: target_person }),
...(await grabCrewOptions()),
},
};
};
@@ -0,0 +1,8 @@
import UserSettingsSection from "./(sections)/user-settings-section";
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
export default function AdminSettingsUserPage() {
return <UserSettingsSection />;
}
export const meta = settingsRecordPageMeta({ kind: "users" });