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:
@@ -85,7 +85,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
|
|
||||||
### Bots (the chat shell)
|
### 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`.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/
|
|||||||
export default async function grabCrewOptions() {
|
export default async function grabCrewOptions() {
|
||||||
const [bots, workspaces] = await Promise.all([
|
const [bots, workspaces] = await Promise.all([
|
||||||
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
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<BUN_SQLITE_COBBLE_WORKSPACES>({
|
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||||
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`,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ export default async function submitUserForm({
|
|||||||
setLoading,
|
setLoading,
|
||||||
setStatus,
|
setStatus,
|
||||||
onSaved,
|
onSaved,
|
||||||
app_context,
|
|
||||||
}: Params) {
|
}: Params) {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
@@ -45,13 +44,6 @@ export default async function submitUserForm({
|
|||||||
confirm_password: form.confirm_password,
|
confirm_password: form.confirm_password,
|
||||||
user_types: form.user_types,
|
user_types: form.user_types,
|
||||||
image: image?.imageBase64Full,
|
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 { UneditableUserTypes } from "@/src/dict/user-types-dict";
|
||||||
import type { UserFormObject } from "@/src/types";
|
import type { UserFormObject } from "@/src/types";
|
||||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
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 parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
import submitUserForm from "../../(functions)/submit-user-form";
|
import submitUserForm from "../../(functions)/submit-user-form";
|
||||||
import UserFormName from "./user-form-name";
|
import UserFormName from "./user-form-name";
|
||||||
@@ -12,7 +11,6 @@ import UserFormAction from "./user-form-action";
|
|||||||
import UserFormEmail from "./user-form-email";
|
import UserFormEmail from "./user-form-email";
|
||||||
import UserFormUsername from "./user-form-username";
|
import UserFormUsername from "./user-form-username";
|
||||||
import UserFormUserTypes from "./user-form-user-types";
|
import UserFormUserTypes from "./user-form-user-types";
|
||||||
import UserFormAccess from "./user-form-access";
|
|
||||||
import UserFormImage from "./user-form-image";
|
import UserFormImage from "./user-form-image";
|
||||||
import UserFormPassword from "./user-form-password";
|
import UserFormPassword from "./user-form-password";
|
||||||
|
|
||||||
@@ -33,9 +31,9 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A user's photo, name, email, username, user types, what they reach
|
* A user's photo, name, email, username, user types and password, saved
|
||||||
* (bots and workspaces) and password, saved in one go through
|
* in one go through `users/save-user`. What they reach is set on their
|
||||||
* `users/save-user`.
|
* settings page.
|
||||||
*/
|
*/
|
||||||
export default function UserForm({
|
export default function UserForm({
|
||||||
person,
|
person,
|
||||||
@@ -53,8 +51,6 @@ export default function UserForm({
|
|||||||
user_types: parseUserTypes({ user_types: person.user_types }).filter(
|
user_types: parseUserTypes({ user_types: person.user_types }).filter(
|
||||||
(type) => !UneditableUserTypes.includes(type),
|
(type) => !UneditableUserTypes.includes(type),
|
||||||
),
|
),
|
||||||
bot_ids: parseIdList({ ids: person.bot_ids }),
|
|
||||||
workspace_ids: parseIdList({ ids: person.workspace_ids }),
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
existing_full: person ? { id: person.id } : undefined,
|
existing_full: person ? { id: person.id } : undefined,
|
||||||
@@ -70,7 +66,6 @@ export default function UserForm({
|
|||||||
<UserFormUsername {...init} />
|
<UserFormUsername {...init} />
|
||||||
</Row>
|
</Row>
|
||||||
<UserFormUserTypes {...init} person={person} />
|
<UserFormUserTypes {...init} person={person} />
|
||||||
<UserFormAccess {...init} person={person} />
|
|
||||||
<UserFormPassword {...init} />
|
<UserFormPassword {...init} />
|
||||||
<UserFormAction
|
<UserFormAction
|
||||||
{...init}
|
{...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.
|
* together. The same form as adding one, embedded on the page.
|
||||||
*/
|
*/
|
||||||
export default function UserEditGroup({ person, onSaved }: Props) {
|
export default function UserEditGroup({ person, onSaved }: Props) {
|
||||||
@@ -20,7 +20,7 @@ export default function UserEditGroup({ person, onSaved }: Props) {
|
|||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
id="edit"
|
id="edit"
|
||||||
title="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
|
<UserForm
|
||||||
key={`${person.id}-${person.updated_at || ""}`}
|
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 type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
import SettingsShell from "../../../(partials)/settings-shell";
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
|
import UserAccessGroups from "../(partials)/user-access-groups";
|
||||||
import UserDangerGroup from "../(partials)/user-danger-group";
|
import UserDangerGroup from "../(partials)/user-danger-group";
|
||||||
import UserEditGroup from "../(partials)/user-edit-group";
|
import UserEditGroup from "../(partials)/user-edit-group";
|
||||||
import UserOverviewGroup from "../(partials)/user-overview-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.
|
* or deleting them where the actor may.
|
||||||
*/
|
*/
|
||||||
export default function UserSettingsSection() {
|
export default function UserSettingsSection() {
|
||||||
@@ -46,6 +48,12 @@ export default function UserSettingsSection() {
|
|||||||
crew_workspaces={pageProps?.crew_workspaces || []}
|
crew_workspaces={pageProps?.crew_workspaces || []}
|
||||||
/>
|
/>
|
||||||
<UserEditGroup person={person} onSaved={setPerson} />
|
<UserEditGroup person={person} onSaved={setPerson} />
|
||||||
|
<UserAccessGroups
|
||||||
|
person={person}
|
||||||
|
crew_bots={pageProps?.crew_bots || []}
|
||||||
|
crew_workspaces={pageProps?.crew_workspaces || []}
|
||||||
|
onUpdated={setPerson}
|
||||||
|
/>
|
||||||
<UserDangerGroup
|
<UserDangerGroup
|
||||||
person={person}
|
person={person}
|
||||||
actor_user_id={pageProps?.user?.id}
|
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 userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import canListUsers from "@/src/utils/rbac/can-list-users";
|
import canListUsers from "@/src/utils/rbac/can-list-users";
|
||||||
import grabUsers from "@/src/functions/backend/db/users/grab-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";
|
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For those who may list users (`canListUsers`: super admins, admins);
|
* For those who may list users (`canListUsers`: super admins, admins);
|
||||||
* everyone else lands on the general settings. Carries every worker and
|
* everyone else lands on the general settings.
|
||||||
* workspace, to pick what each user reaches.
|
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
const settings = await grabSettingsServerProps(params);
|
const settings = await grabSettingsServerProps(params);
|
||||||
@@ -27,7 +25,6 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
props: {
|
props: {
|
||||||
...settings.props,
|
...settings.props,
|
||||||
persons: (await grabUsers()).payload || [],
|
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 }) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
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 grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||||
import saveUser from "@/src/functions/backend/db/users/save-user";
|
import saveUser from "@/src/functions/backend/db/users/save-user";
|
||||||
import setUserProfileImage from "@/src/functions/backend/db/users/set-user-profile-image";
|
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`):
|
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
|
||||||
* name, email, username, password, user types (each checked against the
|
* name, email, username, password, user types (each checked against the
|
||||||
* assignment matrix), when one is sent their profile photo, and when sent
|
* assignment matrix), and when one is sent their profile photo. What they
|
||||||
* the workers assigned to them and the workspaces they're in
|
* reach is set on its own (`users/add-access`, `users/remove-access`).
|
||||||
* (`setUserAccess`). Whoever may create or update users manages the crew
|
* Returns the user.
|
||||||
* too (super admins and admins). Returns the user.
|
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
|
||||||
try {
|
try {
|
||||||
@@ -65,10 +63,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
|
|||||||
await setUserProfileImage({ user_id, data_url: data.image });
|
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({
|
await logEvent({
|
||||||
title: created ? "User created" : "User updated",
|
title: created ? "User created" : "User updated",
|
||||||
category: "auth",
|
category: "auth",
|
||||||
@@ -78,8 +72,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
|
|||||||
user_id,
|
user_id,
|
||||||
user_types: data.user_types,
|
user_types: data.user_types,
|
||||||
password_changed: Boolean(data.password),
|
password_changed: Boolean(data.password),
|
||||||
bot_ids: data.bot_ids,
|
|
||||||
workspace_ids: data.workspace_ids,
|
|
||||||
},
|
},
|
||||||
ip_address: grabRequestIP(req),
|
ip_address: grabRequestIP(req),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ export const SaveUserRequestSchema = z
|
|||||||
* A new profile photo as a base64 data URL; left out keeps theirs.
|
* A new profile photo as a base64 data URL; left out keeps theirs.
|
||||||
*/
|
*/
|
||||||
image: z.string().startsWith("data:image/", "Pick an image").nullish(),
|
image: z.string().startsWith("data:image/", "Pick an image").nullish(),
|
||||||
/**
|
|
||||||
* Every worker assigned to them and every workspace they're in,
|
|
||||||
* replacing the old lists. Left out: unchanged.
|
|
||||||
*/
|
|
||||||
bot_ids: z.array(z.coerce.number().int().positive()).optional(),
|
|
||||||
workspace_ids: z.array(z.coerce.number().int().positive()).optional(),
|
|
||||||
})
|
})
|
||||||
.refine((data) => data.password == data.confirm_password, {
|
.refine((data) => data.password == data.confirm_password, {
|
||||||
message: "The passwords don't match",
|
message: "The passwords don't match",
|
||||||
@@ -45,3 +39,17 @@ export const UserIdRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type UserIdRequest = z.infer<typeof UserIdRequestSchema>;
|
export type UserIdRequest = z.infer<typeof UserIdRequestSchema>;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1051,8 +1051,6 @@ export type AdminFetchParams<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS> = {
|
|||||||
|
|
||||||
export type UserFormObject = BUN_SQLITE_COBBLE_USERS & {
|
export type UserFormObject = BUN_SQLITE_COBBLE_USERS & {
|
||||||
user_types?: UserType[];
|
user_types?: UserType[];
|
||||||
bot_ids?: number[];
|
|
||||||
workspace_ids?: number[];
|
|
||||||
password?: string;
|
password?: string;
|
||||||
confirm_password?: string;
|
confirm_password?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user