diff --git a/AGENTS.md b/AGENTS.md index 8045fd5..4e18160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl ### Bots (the chat shell) -- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the user form's Bots and Workspaces pickers, `user-form-access.tsx`, saved by `save-user` through `setUserAccess`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`, and on a workspace's page is sent to its first bot. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. +- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`, and on a workspace's page is sent to its first bot. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. - **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. diff --git a/src/functions/backend/db/access/grab-crew-options.ts b/src/functions/backend/db/access/grab-crew-options.ts index 5b330a2..f83757d 100644 --- a/src/functions/backend/db/access/grab-crew-options.ts +++ b/src/functions/backend/db/access/grab-crew-options.ts @@ -8,10 +8,10 @@ import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/ export default async function grabCrewOptions() { const [bots, workspaces] = await Promise.all([ BunSQLite.sql({ - sql: `SELECT id, name, role FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`, + sql: `SELECT id, name, role, hue, image_media_id FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`, }), BunSQLite.sql({ - sql: `SELECT id, name FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`, + sql: `SELECT id, name, description, image_media_id FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`, }), ]); diff --git a/src/functions/backend/db/access/set-user-access.ts b/src/functions/backend/db/access/set-user-access.ts deleted file mode 100644 index 594ae51..0000000 --- a/src/functions/backend/db/access/set-user-access.ts +++ /dev/null @@ -1,49 +0,0 @@ -import BunSQLite from "@moduletrace/bun-sqlite"; - -type Params = { - user_id: number | string; - /** - * Every worker assigned to them, replacing the old list. The - * coordinator is never assigned. Left out: unchanged. - */ - bot_ids?: number[]; - /** - * Every workspace they're in, replacing the old list. Left out: - * unchanged. - */ - workspace_ids?: number[]; -}; - -/** - * Sets which workers a user chats with in their own chat (`bot_users`) - * and which workspaces they work in (`workspace_users`). The caller checks - * the actor manages the crew. Ids that aren't a worker or a workspace are - * skipped. - */ -export default async function setUserAccess({ user_id, bot_ids, workspace_ids }: Params) { - const now = Date.now(); - - if (bot_ids) { - await BunSQLite.sql({ sql: `DELETE FROM bot_users WHERE user_id = ?`, values: [user_id] }); - - for (const bot_id of new Set(bot_ids)) { - await BunSQLite.sql({ - sql: `INSERT OR IGNORE INTO bot_users (created_at, updated_at, bot_id, user_id) - SELECT ?, ?, id, ? FROM bots WHERE id = ? AND kind = 'worker'`, - values: [now, now, user_id, bot_id], - }); - } - } - - if (workspace_ids) { - await BunSQLite.sql({ sql: `DELETE FROM workspace_users WHERE user_id = ?`, values: [user_id] }); - - for (const workspace_id of new Set(workspace_ids)) { - await BunSQLite.sql({ - sql: `INSERT OR IGNORE INTO workspace_users (created_at, updated_at, workspace_id, user_id) - SELECT ?, ?, id, ? FROM workspaces WHERE id = ?`, - values: [now, now, user_id, workspace_id], - }); - } - } -} diff --git a/src/functions/backend/db/access/set-user-grant.ts b/src/functions/backend/db/access/set-user-grant.ts new file mode 100644 index 0000000..42ac78d --- /dev/null +++ b/src/functions/backend/db/access/set-user-grant.ts @@ -0,0 +1,40 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { UserGrantKind } from "@/src/schemas/user-schema"; + +type Params = { + user_id: number | string; + /** + * A worker assigned to them (`bot_users`, for their own chat) or a + * workspace they're in (`workspace_users`). + */ + kind: UserGrantKind; + id: number; + /** + * Gives it when true, takes it away when false. + */ + granted: boolean; +}; + +/** + * Gives a user one worker or workspace, or takes it away. The caller + * checks the actor manages the crew. An id that isn't a worker (the + * coordinator is never assigned) or a workspace is skipped. + */ +export default async function setUserGrant({ user_id, kind, id, granted }: Params) { + const table = kind == "bot" ? "bot_users" : "workspace_users"; + const column = kind == "bot" ? "bot_id" : "workspace_id"; + + if (!granted) { + await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ? AND ${column} = ?`, values: [user_id, id] }); + return; + } + + const now = Date.now(); + const source = kind == "bot" ? `bots WHERE id = ? AND kind = 'worker'` : `workspaces WHERE id = ?`; + + await BunSQLite.sql({ + sql: `INSERT OR IGNORE INTO ${table} (created_at, updated_at, ${column}, user_id) + SELECT ?, ?, id, ? FROM ${source}`, + values: [now, now, user_id, id], + }); +} diff --git a/src/pages/admin/people/create/(functions)/submit-user-form.ts b/src/pages/admin/people/create/(functions)/submit-user-form.ts index a2f4534..73c6f15 100644 --- a/src/pages/admin/people/create/(functions)/submit-user-form.ts +++ b/src/pages/admin/people/create/(functions)/submit-user-form.ts @@ -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 || [] } - : {}), }, }); diff --git a/src/pages/admin/people/create/(partials)/user-form/index.tsx b/src/pages/admin/people/create/(partials)/user-form/index.tsx index 4322d24..e61f744 100644 --- a/src/pages/admin/people/create/(partials)/user-form/index.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/index.tsx @@ -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({ - > & { - 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 ( - - ({ - 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 })); - }} - /> - ({ - 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 })); - }} - /> - - ); -} diff --git a/src/pages/admin/settings/users/[user_id]/(partials)/user-access-group.tsx b/src/pages/admin/settings/users/[user_id]/(partials)/user-access-group.tsx new file mode 100644 index 0000000..8d6923a --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-access-group.tsx @@ -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(); + const [error, setError] = useState(); + + 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({ + 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 ( + + + {granted.map((item) => ( + change({ path: "remove-access", item_id: item.id })} + /> + ))} + {granted.length ? null : ( + {empty} + )} + + ({ value: String(item.id), title: item.name }))} + loading={busy_id == "add"} + onAdd={({ value }) => change({ path: "add-access", item_id: Number(value) })} + /> + + + ); +} diff --git a/src/pages/admin/settings/users/[user_id]/(partials)/user-access-groups.tsx b/src/pages/admin/settings/users/[user_id]/(partials)/user-access-groups.tsx new file mode 100644 index 0000000..a65f0d5 --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-access-groups.tsx @@ -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 ( + <> + ({ + id: Number(bot.id), + name: bot.name || `Bot ${bot.id}`, + subtitle: bot.role, + href: `/admin/settings/bots/${bot.id}`, + avatar: ( + + ), + }))} + onUpdated={onUpdated} + /> + ({ + id: Number(workspace.id), + name: workspace.name || `Workspace ${workspace.id}`, + subtitle: workspace.description, + href: `/admin/settings/workspaces/${workspace.id}`, + avatar: , + }))} + onUpdated={onUpdated} + /> + + ); +} diff --git a/src/pages/admin/settings/users/[user_id]/(partials)/user-access-row.tsx b/src/pages/admin/settings/users/[user_id]/(partials)/user-access-row.tsx new file mode 100644 index 0000000..921bf9f --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-access-row.tsx @@ -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 ( + + {avatar} + + {name} + {subtitle ? ( + {subtitle} + ) : null} + + + + ); +} diff --git a/src/pages/admin/settings/users/[user_id]/(partials)/user-edit-group.tsx b/src/pages/admin/settings/users/[user_id]/(partials)/user-edit-group.tsx index 7f4d6cc..40fca93 100644 --- a/src/pages/admin/settings/users/[user_id]/(partials)/user-edit-group.tsx +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-edit-group.tsx @@ -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) { + = async (params) => { const settings = await grabSettingsServerProps(params); @@ -27,7 +25,6 @@ export const server: BunextPageServerFn = async (params) => { props: { ...settings.props, persons: (await grabUsers()).payload || [], - ...(await grabCrewOptions()), }, }; }; diff --git a/src/pages/api/admin/users/(functions)/handle-user-grant.ts b/src/pages/api/admin/users/(functions)/handle-user-grant.ts new file mode 100644 index 0000000..7458980 --- /dev/null +++ b/src/pages/api/admin/users/(functions)/handle-user-grant.ts @@ -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> { + 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 }) }; + } +} diff --git a/src/pages/api/admin/users/add-access.ts b/src/pages/api/admin/users/add-access.ts new file mode 100644 index 0000000..28d80e2 --- /dev/null +++ b/src/pages/api/admin/users/add-access.ts @@ -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> = async ({ req, body }) => + handleUserGrant({ req, body, granted: true }); diff --git a/src/pages/api/admin/users/remove-access.ts b/src/pages/api/admin/users/remove-access.ts new file mode 100644 index 0000000..ff1aaea --- /dev/null +++ b/src/pages/api/admin/users/remove-access.ts @@ -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> = async ({ req, body }) => + handleUserGrant({ req, body, granted: false }); diff --git a/src/pages/api/admin/users/save-user.ts b/src/pages/api/admin/users/save-user.ts index 621b15d..0ea89f3 100644 --- a/src/pages/api/admin/users/save-user.ts +++ b/src/pages/api/admin/users/save-user.ts @@ -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> = async ({ req, body }) => { try { @@ -65,10 +63,6 @@ export const handler: BunextAPIRouteHandler data.password == data.confirm_password, { message: "The passwords don't match", @@ -45,3 +39,17 @@ export const UserIdRequestSchema = z.object({ }); export type UserIdRequest = z.infer; + +export const UserGrantKinds = ["bot", "workspace"] as const; + +export type UserGrantKind = (typeof UserGrantKinds)[number]; + +/** + * One worker or workspace to give a user or take away + * (`users/add-access`, `users/remove-access`). + */ +export const UserGrantRequestSchema = z.object({ + user_id: z.coerce.number().int().positive(), + kind: z.enum(UserGrantKinds), + id: z.coerce.number().int().positive(), +}); diff --git a/src/types/index.ts b/src/types/index.ts index dea4678..8fd8dfe 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1051,8 +1051,6 @@ export type AdminFetchParams = { export type UserFormObject = BUN_SQLITE_COBBLE_USERS & { user_types?: UserType[]; - bot_ids?: number[]; - workspace_ids?: number[]; password?: string; confirm_password?: string; };