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,25 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
type Props = {
open: boolean;
onClose: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Adds a user in a modal. Editing one happens on its settings page.
*/
export default function NewUserModal({ open, onClose, onSaved }: Props) {
return (
<AddModalShell
open={open}
setOpen={(next) => !next && onClose()}
title="Add a user"
description="They get their own coordinator and main computer, and sign in with this password."
>
{open ? <UserForm onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -1,33 +0,0 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
open: boolean;
onClose: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Adds or edits a user in a modal.
*/
export default function UserEditModal({ person, open, onClose, onSaved }: Props) {
return (
<AddModalShell
open={open}
setOpen={(next) => !next && onClose()}
title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"}
description={
person
? "Leave the password empty to keep theirs."
: "They get their own coordinator and main computer, and sign in with this password."
}
>
{open ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -4,7 +4,6 @@ import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
@@ -13,22 +12,20 @@ type Props = {
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
busy?: boolean;
onEdit: () => void;
onRevoke: () => void;
onDelete: () => void;
};
/**
* One user: their photo, name, user types and email, then Edit, Revoke
* (not once revoked) and Delete where the actor may (`canUpdateUser`,
* `canRevokeUser`; never revoking or deleting yourself).
* One user: their photo, name, user types and email. The title opens
* their settings page; Revoke (not once revoked) and Delete sit at the
* end where the actor may (`canRevokeUser`; never yourself).
*/
export default function UserSettingsRow({
person,
actor_user_id,
actor_user_types,
busy,
onEdit,
onRevoke,
onDelete,
}: Props) {
@@ -42,25 +39,17 @@ export default function UserSettingsRow({
.join(", ");
const is_self = person.id == actor_user_id;
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
const can_revoke = can_delete && !user_types.includes("revoked");
return (
<SettingsListRow
avatar={<UserAvatar person={person} size="md" />}
href={`/admin/settings/users/${person.id}`}
title={name}
badge={is_self ? "You" : undefined}
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
>
{can_edit ? (
<SettingsRowAction
title={`Edit ${name}`}
label="Edit"
disabled={busy}
onClick={onEdit}
/>
) : null}
{can_revoke ? (
<SettingsRowAction
title={`Revoke ${name}`}
@@ -8,26 +8,21 @@ import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import UserEditModal from "../(partials)/user-edit-modal";
import NewUserModal from "../(partials)/new-user-modal";
import UserSettingsRow from "../(partials)/user-settings-row";
import revokeUser from "../(functions)/revoke-user";
/**
* Everyone who can sign in (for super admins and admins): add a user
* with a password, edit one (photo, name, email, username, a new
* password, user types), revoke one, or delete one with everything
* that's theirs. What each person
* may do here is the RBAC helpers' call.
* Everyone who can sign in (for super admins and admins). A row opens
* that user's settings page; add one with a password, revoke one, or
* delete one with everything that's theirs.
*/
export default function UsersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { items, setItems, busy, setBusy, status, setStatus, remove } = useSettingsList({
items: pageProps?.persons || [],
});
/**
* The user being edited, or `true` while adding one.
*/
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
const [creating, setCreating] = useState(false);
const savePerson = (person: BUN_SQLITE_COBBLE_USERS_JOIN) =>
setItems((prev) =>
@@ -66,7 +61,6 @@ export default function UsersSettingsSection() {
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
busy={busy}
onEdit={() => setEditing(person)}
onRevoke={() => revoke(person)}
onDelete={() =>
remove({
@@ -80,27 +74,26 @@ export default function UsersSettingsSection() {
))}
</SettingsList>
{canCreateUser({ user_types: pageProps?.user_types }) ? (
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setEditing(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setCreating(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
) : null}
<SettingsStatus status={status} />
<UserEditModal
person={editing === true ? undefined : editing}
open={Boolean(editing)}
onClose={() => setEditing(undefined)}
<NewUserModal
open={creating}
onClose={() => setCreating(false)}
onSaved={({ person, msg }) => {
savePerson(person);
setEditing(undefined);
setCreating(false);
setStatus({ success: true, msg });
}}
/>
@@ -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" });