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:
@@ -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()),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user