Set a user's bots and workspaces in their own groups, saved at once

Take the Bots and Workspaces pickers out of the user form. A user's
settings page lists what they reach in two groups, like a workspace's
Bots group: each row links to its settings and can be taken away, and
a select adds one, saved straight away through users/add-access and
users/remove-access (setUserGrant).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 15:44:59 +01:00
co-authored by Claude Opus 5.5
parent 6294b911e6
commit 391943a1c2
19 changed files with 396 additions and 162 deletions
@@ -20,7 +20,6 @@ export default async function submitUserForm({
setLoading,
setStatus,
onSaved,
app_context,
}: Params) {
if (loading) return;
@@ -45,13 +44,6 @@ export default async function submitUserForm({
confirm_password: form.confirm_password,
user_types: form.user_types,
image: image?.imageBase64Full,
/**
* Only where the form offers them (`UserFormAccess`), so a
* form without them never clears them.
*/
...(app_context.pageProps?.crew_bots
? { bot_ids: form.bot_ids || [], workspace_ids: form.workspace_ids || [] }
: {}),
},
});
@@ -4,7 +4,6 @@ import useFormInit from "@/src/hooks/use-form-init";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import type { UserFormObject } from "@/src/types";
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 submitUserForm from "../../(functions)/submit-user-form";
import UserFormName from "./user-form-name";
@@ -12,7 +11,6 @@ import UserFormAction from "./user-form-action";
import UserFormEmail from "./user-form-email";
import UserFormUsername from "./user-form-username";
import UserFormUserTypes from "./user-form-user-types";
import UserFormAccess from "./user-form-access";
import UserFormImage from "./user-form-image";
import UserFormPassword from "./user-form-password";
@@ -33,9 +31,9 @@ type Props = {
};
/**
* A user's photo, name, email, username, user types, what they reach
* (bots and workspaces) and password, saved in one go through
* `users/save-user`.
* A user's photo, name, email, username, user types and password, saved
* in one go through `users/save-user`. What they reach is set on their
* settings page.
*/
export default function UserForm({
person,
@@ -53,8 +51,6 @@ export default function UserForm({
user_types: parseUserTypes({ user_types: person.user_types }).filter(
(type) => !UneditableUserTypes.includes(type),
),
bot_ids: parseIdList({ ids: person.bot_ids }),
workspace_ids: parseIdList({ ids: person.workspace_ids }),
}
: undefined,
existing_full: person ? { id: person.id } : undefined,
@@ -70,7 +66,6 @@ export default function UserForm({
<UserFormUsername {...init} />
</Row>
<UserFormUserTypes {...init} person={person} />
<UserFormAccess {...init} person={person} />
<UserFormPassword {...init} />
<UserFormAction
{...init}
@@ -1,68 +0,0 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
};
/**
* Picks the workers a user chats with and the workspaces they work in.
* Shown where the page carries the crew (`crew_bots`, `crew_workspaces`),
* and only for a user who'd reach nothing without it: super admins and
* admins reach every worker and workspace anyway.
*/
export default function UserFormAccess({ setForm, form, person, app_context }: Props) {
const bots = app_context.pageProps?.crew_bots;
const workspaces = app_context.pageProps?.crew_workspaces;
if (!bots || !workspaces) return null;
const types = [...parseUserTypes({ user_types: person?.user_types }), ...(form.user_types || [])];
if (types.includes("super_admin") || types.includes("admin")) return null;
return (
<Stack className="w-full gap-4! items-stretch">
<SearchSelect
id="user-form-bots"
title="Bots"
label="Bots"
showLabel
multiple
placeholder="Pick the bots they chat with"
options={bots.map((bot) => ({
value: String(bot.id),
title: bot.role ? `${bot.name} (${bot.role})` : bot.name || `Bot ${bot.id}`,
}))}
defaultValues={(form.bot_ids || []).map(String)}
info="They chat with these in their own chat. They reach no bot until one is picked here or they're in a workspace."
changeHandler={(value) => {
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
setForm((prev) => ({ ...prev, bot_ids: ids }));
}}
/>
<SearchSelect
id="user-form-workspaces"
title="Workspaces"
label="Workspaces"
showLabel
multiple
placeholder="Pick the workspaces they work in"
options={workspaces.map((workspace) => ({
value: String(workspace.id),
title: workspace.name || `Workspace ${workspace.id}`,
}))}
defaultValues={(form.workspace_ids || []).map(String)}
info="They chat with every bot in these workspaces, there."
changeHandler={(value) => {
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
setForm((prev) => ({ ...prev, workspace_ids: ids }));
}}
/>
</Stack>
);
}
@@ -0,0 +1,87 @@
import { useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import InlineAddSelect from "@/src/components/general/inline-add-select";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { UserGrantKind } from "@/src/schemas/user-schema";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseIdList from "@/src/utils/parse-id-list";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";
import UserAccessRow, { type UserAccessItem } from "./user-access-row";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
kind: UserGrantKind;
id: string;
title: string;
description: string;
/**
* Shown when they have none.
*/
empty: string;
/**
* Every worker or every workspace on the install.
*/
items: UserAccessItem[];
onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
};
/**
* The workers or workspaces a user reaches, each added or taken away at
* once (`users/add-access`, `users/remove-access`).
*/
export default function UserAccessGroup({ person, kind, id, title, description, empty, items, onUpdated }: Props) {
const [busy_id, setBusyId] = useState<number | "add">();
const [error, setError] = useState<string>();
const granted_ids = parseIdList({ ids: kind == "bot" ? person.bot_ids : person.workspace_ids });
const granted = items.filter((item) => granted_ids.includes(item.id));
const addable = items.filter((item) => !granted_ids.includes(item.id));
const change = async ({ path, item_id }: { path: "add-access" | "remove-access"; item_id: number }) => {
setBusyId(path == "add-access" ? "add" : item_id);
setError(undefined);
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
path: `users/${path}`,
body: { user_id: person.id, kind, id: item_id },
});
setBusyId(undefined);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "That didn't work.");
return;
}
onUpdated(res.singleRes);
};
return (
<SettingsGroup id={id} title={title} description={description}>
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
{granted.map((item) => (
<UserAccessRow
key={item.id}
{...item}
noun={kind}
removing={busy_id == item.id}
onRemove={() => change({ path: "remove-access", item_id: item.id })}
/>
))}
{granted.length ? null : (
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">{empty}</Span>
)}
</Stack>
<InlineAddSelect
id={`user-add-${kind}`}
noun={kind}
options={addable.map((item) => ({ value: String(item.id), title: item.name }))}
loading={busy_id == "add"}
onAdd={({ value }) => change({ path: "add-access", item_id: Number(value) })}
/>
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
</SettingsGroup>
);
}
@@ -0,0 +1,63 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { BotHue } from "@/src/dict/bot-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 parseUserTypes from "@/src/utils/parse-user-types";
import UserAccessGroup from "./user-access-group";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
crew_bots: BUN_SQLITE_COBBLE_BOTS[];
crew_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
};
/**
* The workers a user chats with and the workspaces they work in. Not for
* super admins and admins: they reach every worker and workspace anyway.
*/
export default function UserAccessGroups({ person, crew_bots, crew_workspaces, onUpdated }: Props) {
const user_types = parseUserTypes({ user_types: person.user_types });
if (user_types.includes("super_admin") || user_types.includes("admin")) return null;
return (
<>
<UserAccessGroup
person={person}
kind="bot"
id="bots"
title="Bots"
description="The workers they chat with in their own chat. They reach no bot until one is added here or they're in a workspace."
empty="No bots yet."
items={crew_bots.map((bot) => ({
id: Number(bot.id),
name: bot.name || `Bot ${bot.id}`,
subtitle: bot.role,
href: `/admin/settings/bots/${bot.id}`,
avatar: (
<BotAvatar image_media_id={bot.image_media_id} hue={(bot.hue || null) as BotHue | null} face="idle" />
),
}))}
onUpdated={onUpdated}
/>
<UserAccessGroup
person={person}
kind="workspace"
id="workspaces"
title="Workspaces"
description="They chat with every bot in these workspaces, there."
empty="No workspaces yet."
items={crew_workspaces.map((workspace) => ({
id: Number(workspace.id),
name: workspace.name || `Workspace ${workspace.id}`,
subtitle: workspace.description,
href: `/admin/settings/workspaces/${workspace.id}`,
avatar: <WorkspaceAvatar image_media_id={workspace.image_media_id} />,
}))}
onUpdated={onUpdated}
/>
</>
);
}
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { X } from "lucide-react";
export type UserAccessItem = {
id: number;
name: string;
subtitle?: string | null;
/**
* Its settings page.
*/
href: string;
avatar: ReactNode;
};
type Props = UserAccessItem & {
/**
* What it is, e.g. "bot", for the remove button's title.
*/
noun: string;
removing?: boolean;
onRemove: () => void;
};
/**
* A worker or workspace the user reaches: opens its settings, or takes it
* away from them.
*/
export default function UserAccessRow({ name, subtitle, href, avatar, noun, removing, onRemove }: Props) {
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
{avatar}
<Link
href={href}
title={`${name}'s settings`}
className="grow min-w-0 flex flex-col border-0 leading-normal hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink rounded-default-sm"
>
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{name}</Span>
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
) : null}
</Link>
<Button
title={`Take this ${noun} away from them`}
aria-label={`Take ${name} away from them`}
type="button"
variant="ghost"
color="text"
loading={removing}
disabled={removing}
onClick={onRemove}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
>
<X size={16} aria-hidden />
</Button>
</Row>
);
}
@@ -10,7 +10,7 @@ type Props = {
};
/**
* The user's profile, photo, user types, access and password, saved
* The user's profile, photo, user types and password, saved
* together. The same form as adding one, embedded on the page.
*/
export default function UserEditGroup({ person, onSaved }: Props) {
@@ -20,7 +20,7 @@ export default function UserEditGroup({ person, onSaved }: Props) {
<SettingsGroup
id="edit"
title="Edit"
description="Their photo, name, contact details, user types, what they reach and a new password."
description="Their photo, name, contact details, user types and a new password."
>
<UserForm
key={`${person.id}-${person.updated_at || ""}`}
@@ -4,12 +4,14 @@ 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 UserAccessGroups from "../(partials)/user-access-groups";
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
* One user's settings: overview, the form that edits them, the bots and
* workspaces they reach, and revoking
* or deleting them where the actor may.
*/
export default function UserSettingsSection() {
@@ -46,6 +48,12 @@ export default function UserSettingsSection() {
crew_workspaces={pageProps?.crew_workspaces || []}
/>
<UserEditGroup person={person} onSaved={setPerson} />
<UserAccessGroups
person={person}
crew_bots={pageProps?.crew_bots || []}
crew_workspaces={pageProps?.crew_workspaces || []}
onUpdated={setPerson}
/>
<UserDangerGroup
person={person}
actor_user_id={pageProps?.user?.id}
@@ -4,13 +4,11 @@ import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import userAuth from "@/src/functions/backend/auth/user-auth";
import canListUsers from "@/src/utils/rbac/can-list-users";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* For those who may list users (`canListUsers`: super admins, admins);
* everyone else lands on the general settings. Carries every worker and
* workspace, to pick what each user reaches.
* everyone else lands on the general settings.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
@@ -27,7 +25,6 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
props: {
...settings.props,
persons: (await grabUsers()).payload || [],
...(await grabCrewOptions()),
},
};
};
@@ -0,0 +1,86 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import setUserGrant from "@/src/functions/backend/db/access/set-user-grant";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { UserGrantRequestSchema } from "@/src/schemas/user-schema";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import AppError from "@/src/utils/app-error";
import parseUserTypes from "@/src/utils/parse-user-types";
import parseWithSchema from "@/src/utils/parse-with-schema";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import canUpdateUser from "@/src/utils/rbac/can-update-user";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
req: Request;
body: unknown;
/**
* Gives the worker or workspace when true, takes it away when false.
*/
granted: boolean;
};
/**
* `users/add-access` and `users/remove-access`: gives a user one worker
* or workspace, or takes it away, for whoever manages the crew and may
* update that user (`canManageCrew`, `canUpdateUser`). Returns the user.
*/
export default async function handleUserGrant({
req,
body,
granted,
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { user_id, kind, id } = parseWithSchema({ schema: UserGrantRequestSchema, data: body });
const target = (await grabUsers({ user_id })).singleRes;
if (!target?.id) {
throw new AppError(`There's no such user.`);
}
const allowed =
canManageCrew({ user_types }) &&
canUpdateUser({
actor_user_types: user_types,
actor_user_id: user.id,
target_user_id: user_id,
target_user_types: parseUserTypes({ user_types: target.user_types }).map((user_type) => ({ user_type })),
update_fields: [],
});
if (!allowed) {
throw new AppError(`You can't do that.`);
}
await setUserGrant({ user_id, kind, id, granted });
await logEvent({
title: granted ? "User access given" : "User access removed",
category: "auth",
user_id: user.id,
plain_text: `User #${user.id} ${granted ? "gave" : "took"} ${kind} #${id} ${granted ? "to" : "from"} user #${user_id}.`,
metadata: { user_id, kind, id, granted },
ip_address: grabRequestIP(req),
});
return { success: true, singleRes: (await grabUsers({ user_id })).singleRes };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import handleUserGrant from "./(functions)/handle-user-grant";
/**
* Gives a user one worker or workspace (`handleUserGrant`). Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) =>
handleUserGrant({ req, body, granted: true });
@@ -0,0 +1,12 @@
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import handleUserGrant from "./(functions)/handle-user-grant";
/**
* Takes one worker or workspace away from a user (`handleUserGrant`). Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) =>
handleUserGrant({ req, body, granted: false });
+3 -11
View File
@@ -1,5 +1,4 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import setUserAccess from "@/src/functions/backend/db/access/set-user-access";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
import saveUser from "@/src/functions/backend/db/users/save-user";
import setUserProfileImage from "@/src/functions/backend/db/users/set-user-profile-image";
@@ -21,10 +20,9 @@ import type {
/**
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
* name, email, username, password, user types (each checked against the
* assignment matrix), when one is sent their profile photo, and when sent
* the workers assigned to them and the workspaces they're in
* (`setUserAccess`). Whoever may create or update users manages the crew
* too (super admins and admins). Returns the user.
* assignment matrix), and when one is sent their profile photo. What they
* reach is set on its own (`users/add-access`, `users/remove-access`).
* Returns the user.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
try {
@@ -65,10 +63,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
await setUserProfileImage({ user_id, data_url: data.image });
}
if (data.bot_ids || data.workspace_ids) {
await setUserAccess({ user_id, bot_ids: data.bot_ids, workspace_ids: data.workspace_ids });
}
await logEvent({
title: created ? "User created" : "User updated",
category: "auth",
@@ -78,8 +72,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
user_id,
user_types: data.user_types,
password_changed: Boolean(data.password),
bot_ids: data.bot_ids,
workspace_ids: data.workspace_ids,
},
ip_address: grabRequestIP(req),
});