From 7667f2bc2f7325c9ad171ec935db03f5e4df87fc Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 13:17:17 +0100 Subject: [PATCH] Add dedicated bot, workspace and user settings pages --- .../general}/inline-add-select.tsx | 0 src/dict/settings-record-sections-dict.ts | 58 +++++++ .../db/workspaces/grab-bot-workspaces.ts | 51 ++++++ .../frontend}/save-bot-harness-settings.ts | 0 .../save-workspace-harness-settings.ts | 0 src/layouts/bots/(partials)/add-flow.tsx | 2 +- .../bots/(partials)/add-form-actions.tsx | 30 ++-- .../bots/(partials)/add-workspace-form.tsx | 2 +- src/layouts/bots/(partials)/bot-form.tsx | 29 +++- .../bots/(partials)/rail-settings-aside.tsx | 127 +++++++++++++++ .../(partials)/rail-settings-record-row.tsx | 39 +++++ src/layouts/bots/(partials)/workspace-row.tsx | 10 +- src/layouts/bots/(sections)/bots-rail.tsx | 4 +- .../[bot_id]/(partials)/bot-models-modal.tsx | 2 +- .../(partials)/conversation-header.tsx | 18 ++- .../(partials)/header-icon-button.tsx | 14 +- .../models/(sections)/bot-models-section.tsx | 25 --- .../bots/[bot_id]/models/index.server.ts | 41 ----- .../admin/bots/[bot_id]/models/index.tsx | 17 -- .../create/(partials)/user-form/index.tsx | 17 +- .../(partials)/user-form/user-form-action.tsx | 18 ++- .../(functions)/settings-record-page-meta.ts | 51 ++++++ .../(partials)/bot-harness-form.tsx | 4 +- .../(partials)/settings-breadcrumbs.tsx | 50 ++++++ .../settings/(partials)/settings-list-row.tsx | 29 +++- .../(partials)/settings-section-heading.tsx | 36 +++++ .../settings/(partials)/settings-shell.tsx | 9 ++ .../bots/(partials)/bot-edit-modal.tsx | 32 ---- .../bots/(partials)/bot-settings-row.tsx | 14 +- .../bots/(sections)/bots-settings-section.tsx | 11 +- .../[bot_id]/(partials)/bot-computer-row.tsx | 46 ++++++ .../(partials)/bot-computers-group.tsx | 151 ++++++++++++++++++ .../[bot_id]/(partials)/bot-delete-group.tsx | 68 ++++++++ .../[bot_id]/(partials)/bot-details-group.tsx | 41 +++++ .../(partials)/bot-overview-group.tsx | 65 ++++++++ .../[bot_id]/(partials)/bot-workspace-row.tsx | 52 ++++++ .../(partials)/bot-workspaces-group.tsx | 146 +++++++++++++++++ .../(sections)/bot-settings-section.tsx | 76 +++++++++ .../settings/bots/[bot_id]/index.server.ts | 70 ++++++++ .../admin/settings/bots/[bot_id]/index.tsx | 8 + .../users/(partials)/new-user-modal.tsx | 25 +++ .../users/(partials)/user-edit-modal.tsx | 33 ---- .../users/(partials)/user-settings-row.tsx | 19 +-- .../(sections)/users-settings-section.tsx | 49 +++--- .../(partials)/user-danger-group.tsx | 142 ++++++++++++++++ .../[user_id]/(partials)/user-edit-group.tsx | 37 +++++ .../(partials)/user-overview-group.tsx | 98 ++++++++++++ .../(sections)/user-settings-section.tsx | 57 +++++++ .../settings/users/[user_id]/index.server.ts | 42 +++++ .../admin/settings/users/[user_id]/index.tsx | 8 + .../(partials)/workspace-settings-row.tsx | 2 +- .../(partials)/workspace-bot-modal.tsx | 0 .../(partials)/workspace-bot-row.tsx | 66 ++++++-- .../(partials)/workspace-bots-group.tsx | 10 +- .../(partials)/workspace-computer-row.tsx | 0 .../(partials)/workspace-computers-group.tsx | 3 +- .../(partials)/workspace-delete-group.tsx | 4 +- .../(partials)/workspace-details-group.tsx | 1 + .../(partials)/workspace-image-group.tsx | 2 +- .../(partials)/workspace-models.tsx | 2 +- .../(sections)/workspace-overview.tsx | 35 ++-- .../workspaces/[workspace_id]/index.server.ts | 65 ++++++++ .../workspaces/[workspace_id]/index.tsx | 8 + .../(partials)/workspace-section-heading.tsx | 20 --- .../bots/[bot_id]/index.server.ts | 14 +- .../workspaces/[workspace_id]/index.server.ts | 66 +++----- .../admin/workspaces/[workspace_id]/index.tsx | 20 ++- src/pages/admin/workspaces/index.server.ts | 14 +- src/pages/api/admin/bots/add-computer.ts | 67 ++++++++ src/pages/api/admin/bots/remove-computer.ts | 71 ++++++++ src/schemas/bot-schema.ts | 10 ++ src/types/index.ts | 20 ++- src/utils/parse-settings-record-path.test.ts | 36 +++++ src/utils/parse-settings-record-path.ts | 32 ++++ 74 files changed, 2183 insertions(+), 358 deletions(-) rename src/{pages/admin/workspaces/[workspace_id]/(partials) => components/general}/inline-add-select.tsx (100%) create mode 100644 src/dict/settings-record-sections-dict.ts create mode 100644 src/functions/backend/db/workspaces/grab-bot-workspaces.ts rename src/{pages/admin/bots/[bot_id]/models/(functions) => functions/frontend}/save-bot-harness-settings.ts (100%) rename src/{pages/admin/workspaces/[workspace_id]/(functions) => functions/frontend}/save-workspace-harness-settings.ts (100%) create mode 100644 src/layouts/bots/(partials)/rail-settings-aside.tsx create mode 100644 src/layouts/bots/(partials)/rail-settings-record-row.tsx delete mode 100644 src/pages/admin/bots/[bot_id]/models/(sections)/bot-models-section.tsx delete mode 100644 src/pages/admin/bots/[bot_id]/models/index.server.ts delete mode 100644 src/pages/admin/bots/[bot_id]/models/index.tsx create mode 100644 src/pages/admin/settings/(functions)/settings-record-page-meta.ts rename src/pages/admin/{bots/[bot_id] => settings}/(partials)/bot-harness-form.tsx (93%) create mode 100644 src/pages/admin/settings/(partials)/settings-breadcrumbs.tsx create mode 100644 src/pages/admin/settings/(partials)/settings-section-heading.tsx delete mode 100644 src/pages/admin/settings/bots/(partials)/bot-edit-modal.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computer-row.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computers-group.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-delete-group.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-details-group.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-overview-group.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspaces-group.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx create mode 100644 src/pages/admin/settings/bots/[bot_id]/index.server.ts create mode 100644 src/pages/admin/settings/bots/[bot_id]/index.tsx create mode 100644 src/pages/admin/settings/users/(partials)/new-user-modal.tsx delete mode 100644 src/pages/admin/settings/users/(partials)/user-edit-modal.tsx create mode 100644 src/pages/admin/settings/users/[user_id]/(partials)/user-danger-group.tsx create mode 100644 src/pages/admin/settings/users/[user_id]/(partials)/user-edit-group.tsx create mode 100644 src/pages/admin/settings/users/[user_id]/(partials)/user-overview-group.tsx create mode 100644 src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx create mode 100644 src/pages/admin/settings/users/[user_id]/index.server.ts create mode 100644 src/pages/admin/settings/users/[user_id]/index.tsx rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx (100%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx (54%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-bots-group.tsx (91%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-computer-row.tsx (100%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-computers-group.tsx (98%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-delete-group.tsx (94%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-details-group.tsx (99%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-image-group.tsx (94%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(partials)/workspace-models.tsx (93%) rename src/pages/admin/{ => settings}/workspaces/[workspace_id]/(sections)/workspace-overview.tsx (65%) create mode 100644 src/pages/admin/settings/workspaces/[workspace_id]/index.server.ts create mode 100644 src/pages/admin/settings/workspaces/[workspace_id]/index.tsx delete mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-section-heading.tsx create mode 100644 src/pages/api/admin/bots/add-computer.ts create mode 100644 src/pages/api/admin/bots/remove-computer.ts create mode 100644 src/utils/parse-settings-record-path.test.ts create mode 100644 src/utils/parse-settings-record-path.ts diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/inline-add-select.tsx b/src/components/general/inline-add-select.tsx similarity index 100% rename from src/pages/admin/workspaces/[workspace_id]/(partials)/inline-add-select.tsx rename to src/components/general/inline-add-select.tsx diff --git a/src/dict/settings-record-sections-dict.ts b/src/dict/settings-record-sections-dict.ts new file mode 100644 index 0000000..c72af8f --- /dev/null +++ b/src/dict/settings-record-sections-dict.ts @@ -0,0 +1,58 @@ +import { + Bot, + CircleAlert, + CpuAi, + Image, + Info, + Layers, + Monitor, + Pencil, + Trash2, + type LucideIcon, +} from "lucide-react"; +import type { SettingsRecordKind } from "@/src/utils/parse-settings-record-path"; + +export type SettingsRecordSection = { + title: string; + url: string; + Icon: LucideIcon; +}; + +/** + * Where the record rail's back link goes, and the sections it lists. + */ +export const SettingsRecordBack: Record< + SettingsRecordKind, + { title: string; href: string } +> = { + bots: { title: "All bots", href: "/admin/settings/bots" }, + workspaces: { title: "All workspaces", href: "/admin/settings/workspaces" }, + users: { title: "All users", href: "/admin/settings/users" }, +}; + +export const SettingsRecordSections: Record< + SettingsRecordKind, + SettingsRecordSection[] +> = { + bots: [ + { title: "Overview", url: "#overview", Icon: Info }, + { title: "Details", url: "#details", Icon: Pencil }, + { title: "Models", url: "#models", Icon: CpuAi }, + { title: "Workspaces", url: "#workspaces", Icon: Layers }, + { title: "Computers", url: "#computers", Icon: Monitor }, + { title: "Delete", url: "#delete", Icon: Trash2 }, + ], + workspaces: [ + { title: "Picture", url: "#picture", Icon: Image }, + { title: "Details", url: "#details", Icon: Pencil }, + { title: "Bots", url: "#bots", Icon: Bot }, + { title: "Computers", url: "#computers", Icon: Monitor }, + { title: "Models", url: "#models", Icon: CpuAi }, + { title: "Delete", url: "#delete", Icon: Trash2 }, + ], + users: [ + { title: "Overview", url: "#overview", Icon: Info }, + { title: "Edit", url: "#edit", Icon: Pencil }, + { title: "Danger zone", url: "#danger", Icon: CircleAlert }, + ], +}; diff --git a/src/functions/backend/db/workspaces/grab-bot-workspaces.ts b/src/functions/backend/db/workspaces/grab-bot-workspaces.ts new file mode 100644 index 0000000..42d984a --- /dev/null +++ b/src/functions/backend/db/workspaces/grab-bot-workspaces.ts @@ -0,0 +1,51 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACE_BOTS } from "@/db/types/db"; +import type { BotWorkspace } from "@/src/types"; +import grabUserWorkspaces from "./grab-user-workspaces"; + +type Params = { + user_id: number | string; + bot: Pick; +}; + +/** + * The workspaces a bot works in, for its settings page. The coordinator + * works in every workspace; a worker only in the ones it was added to. + * Each row carries what the bot has for that workspace alone. + */ +export default async function grabBotWorkspaces({ + user_id, + bot, +}: Params): Promise { + const workspaces = await grabUserWorkspaces({ user_id }); + + if (bot.kind == "coordinator") { + return workspaces.map((workspace) => ({ + ...workspace, + workspace_system_prompt: null, + workspace_memory: null, + })); + } + + const rows = + ( + await BunSQLite.sql({ + sql: `SELECT workspace_id, system_prompt, memory FROM workspace_bots WHERE bot_id = ?`, + values: [Number(bot.id)], + }) + ).payload || []; + + const by_workspace = new Map(rows.map((row) => [Number(row.workspace_id), row])); + + return workspaces + .filter((workspace) => by_workspace.has(Number(workspace.id))) + .map((workspace) => { + const row = by_workspace.get(Number(workspace.id)); + + return { + ...workspace, + workspace_system_prompt: row?.system_prompt || null, + workspace_memory: row?.memory || null, + }; + }); +} diff --git a/src/pages/admin/bots/[bot_id]/models/(functions)/save-bot-harness-settings.ts b/src/functions/frontend/save-bot-harness-settings.ts similarity index 100% rename from src/pages/admin/bots/[bot_id]/models/(functions)/save-bot-harness-settings.ts rename to src/functions/frontend/save-bot-harness-settings.ts diff --git a/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings.ts b/src/functions/frontend/save-workspace-harness-settings.ts similarity index 100% rename from src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings.ts rename to src/functions/frontend/save-workspace-harness-settings.ts diff --git a/src/layouts/bots/(partials)/add-flow.tsx b/src/layouts/bots/(partials)/add-flow.tsx index 8c2f53d..57df182 100644 --- a/src/layouts/bots/(partials)/add-flow.tsx +++ b/src/layouts/bots/(partials)/add-flow.tsx @@ -122,7 +122,7 @@ export default function AddFlow({ children }: Props) { const pathname = pageProps?.url?.pathname; if ( - (linked && pathname == `/admin/workspaces/${workspace?.id}`) || + (linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) || pathname == SettingsComputersPath ) { window.location.reload(); diff --git a/src/layouts/bots/(partials)/add-form-actions.tsx b/src/layouts/bots/(partials)/add-form-actions.tsx index 18dfbdd..4899faf 100644 --- a/src/layouts/bots/(partials)/add-form-actions.tsx +++ b/src/layouts/bots/(partials)/add-form-actions.tsx @@ -7,7 +7,11 @@ type Props = { submit_label: string; loading?: boolean; error?: string; - onCancel: () => void; + /** + * Left out on a form embedded in a settings page: then there is + * nothing to cancel into. + */ + onCancel?: () => void; }; /** @@ -19,17 +23,19 @@ export default function AddFormActions({ submit_label, loading, error, onCancel - + {onCancel ? ( + + ) : null} diff --git a/src/layouts/bots/(partials)/add-workspace-form.tsx b/src/layouts/bots/(partials)/add-workspace-form.tsx index 8bd3ea2..39a5ee7 100644 --- a/src/layouts/bots/(partials)/add-workspace-form.tsx +++ b/src/layouts/bots/(partials)/add-workspace-form.tsx @@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) { await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null); } - window.location.href = `/admin/workspaces/${workspace_id}`; + window.location.href = `/admin/settings/workspaces/${workspace_id}`; }; return ( diff --git a/src/layouts/bots/(partials)/bot-form.tsx b/src/layouts/bots/(partials)/bot-form.tsx index a4c2149..9981a00 100644 --- a/src/layouts/bots/(partials)/bot-form.tsx +++ b/src/layouts/bots/(partials)/bot-form.tsx @@ -20,7 +20,15 @@ type Props = { * Edits this bot; adds a worker when not given. */ bot?: BUN_SQLITE_COBBLE_BOTS; - onCancel: () => void; + /** + * Left out when the form is embedded in a settings page. + */ + onCancel?: () => void; + /** + * Called with the saved bot instead of reloading. For the settings + * page, which updates in place. + */ + onSaved?: (bot: BUN_SQLITE_COBBLE_BOTS) => void; }; /** @@ -30,7 +38,7 @@ type Props = { * The coordinator keeps its name and has no colour: only its picture and * instructions change. */ -export default function BotForm({ bot, onCancel }: Props) { +export default function BotForm({ bot, onCancel, onSaved }: Props) { const { pageProps } = useContext(AppContext); const workspace = pageProps?.workspace; const crew = pageProps?.all_bots || pageProps?.bots || []; @@ -72,6 +80,8 @@ export default function BotForm({ bot, onCancel }: Props) { return; } + let image_media_id: number | null | undefined; + if (image.changed) { const image_res = await setProfileImage({ target: "bot", @@ -84,9 +94,24 @@ export default function BotForm({ bot, onCancel }: Props) { setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image."); return; } + + image_media_id = image_res.singleRes?.image_media_id; } if (bot?.id) { + const saved = res.singleRes as BUN_SQLITE_COBBLE_BOTS | undefined; + + if (onSaved && saved?.id) { + setLoading(false); + onSaved({ + ...saved, + ...(image_media_id === undefined + ? {} + : { image_media_id: image_media_id ?? undefined }), + }); + return; + } + window.location.reload(); return; } diff --git a/src/layouts/bots/(partials)/rail-settings-aside.tsx b/src/layouts/bots/(partials)/rail-settings-aside.tsx new file mode 100644 index 0000000..05198c4 --- /dev/null +++ b/src/layouts/bots/(partials)/rail-settings-aside.tsx @@ -0,0 +1,127 @@ +import { useContext, type ReactNode } from "react"; +import Divider from "@moduletrace/twui/layout/Divider"; +import BotAvatar from "@/src/components/general/bot-avatar"; +import UserAvatar from "@/src/components/general/user-avatar"; +import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; +import { UserTypes } from "@/src/dict/user-types-dict"; +import type { BotFace, BotHue } from "@/src/dict/bot-dict"; +import { + SettingsRecordBack, + SettingsRecordSections, +} from "@/src/dict/settings-record-sections-dict"; +import { AppContext } from "@/src/pages/__root"; +import parseSettingsRecordPath from "@/src/utils/parse-settings-record-path"; +import parseUserTypes from "@/src/utils/parse-user-types"; +import RailBackLink from "./rail-back-link"; +import RailSettingsLink from "./rail-settings-link"; +import RailSettingsList from "./rail-settings-list"; +import RailSettingsRecordRow from "./rail-settings-record-row"; + +type Props = { + pathname?: string; +}; + +/** + * Settings mode of the rail. On a list page it shows the settings sub + * pages; on a bot, workspace or user record page it drills into that + * record: a back link to its list, the record, then its sections. + */ +export default function RailSettingsAside({ pathname }: Props) { + const { pageProps } = useContext(AppContext); + const record = parseSettingsRecordPath({ pathname }); + + if (!record) { + return ; + } + + const back = SettingsRecordBack[record.kind]; + const sections = SettingsRecordSections[record.kind].filter((section) => { + if ( + record.kind == "bots" && + section.url == "#delete" && + pageProps?.bot?.kind == "coordinator" + ) { + return false; + } + + if (section.url == "#models") { + return record.kind == "bots" + ? Boolean(pageProps?.bot_harness_overview?.harnesses?.length) + : record.kind == "workspaces" + ? Boolean( + pageProps?.workspace_harness_overview?.harnesses?.length, + ) + : true; + } + + return true; + }); + + let avatar: ReactNode = null; + let title = ""; + let subtitle = ""; + + if (record.kind == "bots") { + const bot = pageProps?.bot; + + if (!bot?.id) return ; + + title = bot.name || `Bot ${bot.id}`; + subtitle = + bot.role || + (bot.kind == "coordinator" + ? "Coordinates your crew" + : "Worker bot"); + avatar = ( + + ); + } else if (record.kind == "workspaces") { + const workspace = pageProps?.workspace; + + if (!workspace?.id) return ; + + title = workspace.name || `Workspace ${workspace.id}`; + subtitle = workspace.description || "A workspace"; + avatar = ( + + ); + } else { + const person = pageProps?.target_person; + + if (!person?.id) return ; + + const types = UserTypes.filter((type) => + parseUserTypes({ user_types: person.user_types }).includes( + type.value, + ), + ) + .map((type) => type.title) + .join(", "); + + title = + [person.first_name, person.last_name].filter(Boolean).join(" ") || + person.email || + `User ${person.id}`; + subtitle = types || person.email || "User"; + avatar = ; + } + + return ( + <> + + + + {sections.map((section) => ( + + ))} + + ); +} diff --git a/src/layouts/bots/(partials)/rail-settings-record-row.tsx b/src/layouts/bots/(partials)/rail-settings-record-row.tsx new file mode 100644 index 0000000..4cd01e5 --- /dev/null +++ b/src/layouts/bots/(partials)/rail-settings-record-row.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; + +type Props = { + avatar: ReactNode; + title: string; + subtitle?: string; +}; + +/** + * The record whose settings are open, at the top of its rail. Same 48px + * row as a bot, selected. + */ +export default function RailSettingsRecordRow({ + avatar, + title, + subtitle, +}: Props) { + return ( + + {avatar} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + ); +} diff --git a/src/layouts/bots/(partials)/workspace-row.tsx b/src/layouts/bots/(partials)/workspace-row.tsx index 43e62c8..52da35a 100644 --- a/src/layouts/bots/(partials)/workspace-row.tsx +++ b/src/layouts/bots/(partials)/workspace-row.tsx @@ -1,9 +1,12 @@ +import { useContext } from "react"; import Link from "@moduletrace/twui/layout/Link"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; import { twMerge } from "tailwind-merge"; +import { AppContext } from "@/src/pages/__root"; import type { WorkspaceWithActivity } from "@/src/types"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; type Props = { workspace: WorkspaceWithActivity; @@ -15,9 +18,14 @@ type Props = { * hue, so unread is said in words beside the description. */ export default function WorkspaceRow({ workspace, selected }: Props) { + const { pageProps } = useContext(AppContext); + const href = canManageCrew({ user_types: pageProps?.user_types }) + ? `/admin/settings/workspaces/${workspace.id}` + : `/admin/workspaces/${workspace.id}`; + return ( {is_settings_page ? ( - + ) : is_rail_page ? ( ) : in_workspace && live_workspace ? ( diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx index 579e138..339a074 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx @@ -9,7 +9,7 @@ import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; -import BotHarnessForm from "./bot-harness-form"; +import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form"; import HeaderIconButton from "./header-icon-button"; type Props = { diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx index 3832d0f..67f4e2d 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx @@ -2,15 +2,19 @@ import { useContext, useState } from "react"; import Row from "@moduletrace/twui/layout/Row"; import Stack from "@moduletrace/twui/layout/Stack"; import H1 from "@moduletrace/twui/layout/H1"; -import { CpuAi, Layers, Monitor } from "lucide-react"; +import { CpuAi, Layers, Monitor, Settings } from "lucide-react"; import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; +import { AppContext } from "@/src/pages/__root"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; +import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import { BotComputersContext } from "../(data)/bot-computers-context"; import BotComputersModal from "./bot-computers-modal"; import BotModelsModal from "./bot-models-modal"; import BotProfileModal from "./bot-profile-modal"; import BotSessionsModal from "./bot-sessions-modal"; import NewSessionButton from "./new-session-button"; +import HeaderIconButton from "./header-icon-button"; import HeaderMetaItem from "./header-meta-item"; type Props = { @@ -36,9 +40,14 @@ type Props = { export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) { const [harnesses, setHarnesses] = useState(initial); const { computers } = useContext(BotComputersContext); + const { pageProps } = useContext(AppContext); const model = harnesses?.find((h) => h.harness == "text")?.current; const computer = computers[0]; + const can_open_settings = + bot.kind == "coordinator" + ? isPassThroughUser({ user_types: pageProps?.user_types }) + : canManageCrew({ user_types: pageProps?.user_types }); return ( @@ -84,6 +93,13 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h harnesses={harnesses} onSaved={setHarnesses} /> + {can_open_settings ? ( + + ) : null} ); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/header-icon-button.tsx b/src/pages/admin/bots/[bot_id]/(partials)/header-icon-button.tsx index 183327d..156a3a5 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/header-icon-button.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/header-icon-button.tsx @@ -13,13 +13,24 @@ type Props = { * For a button that acts on its own; a modal's target leaves it out. */ onClick?: () => void; + /** + * For a button that opens a page. + */ + href?: string; }; /** * A ghost icon button on the right of the conversation header, usually a * modal's target. */ -export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) { +export default function HeaderIconButton({ + icon: Icon, + title, + disabled, + loading, + onClick, + href, +}: Props) { return ( - setEditing(undefined)} /> ); } diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computer-row.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computer-row.tsx new file mode 100644 index 0000000..c71adea --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computer-row.tsx @@ -0,0 +1,46 @@ +import ComputerIconTile from "@/src/components/general/computer-icon-tile"; +import type { ComputerWithStatus } from "@/src/types"; +import computerResourcesLabel from "@/src/utils/computer-resources-label"; +import computerStatusLabel from "@/src/utils/computer-status-label"; +import SettingsListRow from "../../../(partials)/settings-list-row"; +import SettingsRowAction from "../../../(partials)/settings-row-action"; + +type Props = { + computer: ComputerWithStatus; + busy?: boolean; + onRemove?: () => void; +}; + +/** + * One computer the bot can use in its own chat. + */ +export default function BotComputerRow({ + computer, + busy, + onRemove, +}: Props) { + const name = computer.name || `Computer ${computer.id}`; + + return ( + } + title={name} + subtitle={[ + computerStatusLabel({ computer }), + computerResourcesLabel({ computer }), + computer.description, + ] + .filter(Boolean) + .join(" · ")} + > + {onRemove ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computers-group.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computers-group.tsx new file mode 100644 index 0000000..289abce --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-computers-group.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import Button from "@moduletrace/twui/layout/Button"; +import { Monitor } from "lucide-react"; +import InlineAddSelect from "@/src/components/general/inline-add-select"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import type { BotWithLastMessage, ComputerWithStatus } from "@/src/types"; +import SettingsGroup from "../../../(partials)/settings-group"; +import SettingsList from "../../../(partials)/settings-list"; +import SettingsStatus from "../../../(partials)/settings-status"; +import BotComputerRow from "./bot-computer-row"; + +type Props = { + bot: BotWithLastMessage; + /** + * Computers it can use in its own chat. + */ + computers: ComputerWithStatus[]; + /** + * Every computer the user has. + */ + all_computers: ComputerWithStatus[]; +}; + +type Status = { error?: boolean; success?: boolean; msg?: string }; + +/** + * The bot's direct computer access. In a workspace it uses that + * workspace's computers instead; the coordinator can use every computer. + */ +export default function BotComputersGroup({ + bot, + computers: initial, + all_computers, +}: Props) { + const [computers, setComputers] = useState(initial); + const [busy_id, setBusyId] = useState(); + const [status, setStatus] = useState(); + const is_coordinator = bot.kind == "coordinator"; + + const addable = all_computers.filter( + (computer) => !computers.some((item) => item.id == computer.id), + ); + + const change = async ({ + path, + computer_id, + }: { + path: "add-computer" | "remove-computer"; + computer_id: number; + }) => { + setBusyId(path == "add-computer" ? "add" : computer_id); + setStatus(undefined); + + const res = await postAdminApi({ + path: `bots/${path}`, + body: { bot_id: bot.id, computer_id }, + }); + + setBusyId(undefined); + + if (!res.success) { + setStatus({ error: true, msg: res.msg || "That didn't work." }); + return; + } + + if (path == "add-computer") { + const computer = all_computers.find( + (item) => Number(item.id) == computer_id, + ); + + if (computer) { + setComputers((prev) => [...prev, computer]); + } + } else { + setComputers((prev) => + prev.filter((computer) => Number(computer.id) != computer_id), + ); + } + + setStatus({ success: true, msg: res.msg || "Saved." }); + }; + + return ( + + + {computers.map((computer) => ( + + change({ + path: "remove-computer", + computer_id: Number(computer.id), + }) + } + /> + ))} + + {is_coordinator ? null : ( + ({ + value: String(computer.id), + title: + computer.name || `Computer ${computer.id}`, + }))} + loading={busy_id == "add"} + onAdd={({ value }) => + change({ + path: "add-computer", + computer_id: Number(value), + }) + } + /> + )} + + + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-delete-group.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-delete-group.tsx new file mode 100644 index 0000000..192cc49 --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-delete-group.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import Button from "@moduletrace/twui/layout/Button"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import type { BotWithLastMessage } from "@/src/types"; +import SettingsGroup from "../../../(partials)/settings-group"; +import SettingsStatus from "../../../(partials)/settings-status"; + +type Props = { + bot: BotWithLastMessage; +}; + +/** + * Deletes a worker with its conversations, tasks and models. The + * coordinator is never deleted. + */ +export default function BotDeleteGroup({ bot }: Props) { + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState<{ error?: boolean; msg?: string }>(); + const name = bot.name || "this bot"; + + const remove = async () => { + if ( + !window.confirm( + `Delete ${name}? Its conversations, tasks and models go with it.`, + ) + ) + return; + + setLoading(true); + setStatus(undefined); + + const res = await postAdminApi({ + path: "bots/delete-bot", + body: { bot_id: bot.id }, + }); + + if (res.success) { + window.location.href = "/admin/settings/bots"; + return; + } + + setLoading(false); + setStatus({ error: true, msg: res.msg || "Couldn't delete the bot." }); + }; + + return ( + + + + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-details-group.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-details-group.tsx new file mode 100644 index 0000000..1b23f89 --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-details-group.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import BotForm from "@/src/layouts/bots/(partials)/bot-form"; +import type { BotWithLastMessage } from "@/src/types"; +import SettingsGroup from "../../../(partials)/settings-group"; +import SettingsStatus from "../../../(partials)/settings-status"; + +type Props = { + bot: BotWithLastMessage; + onSaved: (bot: BUN_SQLITE_COBBLE_BOTS) => void; +}; + +/** + * The bot's picture, name, job, colour and core instructions, edited in + * place. The same form as adding a bot, without a modal around it. + */ +export default function BotDetailsGroup({ bot, onSaved }: Props) { + const [status, setStatus] = useState<{ success?: boolean; msg?: string }>(); + const is_coordinator = bot.kind == "coordinator"; + + return ( + + { + onSaved(saved); + setStatus({ success: true, msg: "Saved." }); + }} + /> + + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-overview-group.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-overview-group.tsx new file mode 100644 index 0000000..c775cfb --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-overview-group.tsx @@ -0,0 +1,65 @@ +import Button from "@moduletrace/twui/layout/Button"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import { MessageSquare } from "lucide-react"; +import BotAvatar from "@/src/components/general/bot-avatar"; +import type { BotFace, BotHue } from "@/src/dict/bot-dict"; +import type { BotWithLastMessage } from "@/src/types"; +import SettingsGroup from "../../../(partials)/settings-group"; + +type Props = { + bot: BotWithLastMessage; +}; + +/** + * The bot at a glance: its stone or picture, name, job and status, then a + * way into its chat. + */ +export default function BotOverviewGroup({ bot }: Props) { + const name = bot.name || `Bot ${bot.id}`; + const is_coordinator = bot.kind == "coordinator"; + + return ( + + + + + + {name} + + + {[ + is_coordinator ? "Coordinator" : "Worker", + bot.role, + bot.status_line, + ] + .filter(Boolean) + .join(" · ")} + + + + + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx new file mode 100644 index 0000000..ab0db87 --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx @@ -0,0 +1,52 @@ +import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; +import type { BotWorkspace } from "@/src/types"; +import SettingsListRow from "../../../(partials)/settings-list-row"; +import SettingsRowAction from "../../../(partials)/settings-row-action"; + +type Props = { + workspace: BotWorkspace; + busy?: boolean; + onRemove?: () => void; +}; + +/** + * One workspace the bot works in. Its title opens the workspace's + * settings; a worker can be taken out from here. + */ +export default function BotWorkspaceRow({ + workspace, + busy, + onRemove, +}: Props) { + const name = workspace.name || `Workspace ${workspace.id}`; + const has_own = Boolean( + workspace.workspace_system_prompt || workspace.workspace_memory, + ); + + return ( + + } + href={`/admin/settings/workspaces/${workspace.id}`} + title={name} + subtitle={ + has_own + ? "Has instructions for this workspace" + : workspace.description || "Uses the workspace's context" + } + > + {onRemove ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspaces-group.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspaces-group.tsx new file mode 100644 index 0000000..ea6f965 --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspaces-group.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import type { BotWorkspace, BotWithLastMessage, WorkspaceWithActivity } from "@/src/types"; +import InlineAddSelect from "@/src/components/general/inline-add-select"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import SettingsGroup from "../../../(partials)/settings-group"; +import SettingsList from "../../../(partials)/settings-list"; +import SettingsStatus from "../../../(partials)/settings-status"; +import BotWorkspaceRow from "./bot-workspace-row"; + +type Props = { + bot: BotWithLastMessage; + /** + * Every workspace the user has. + */ + workspaces: WorkspaceWithActivity[]; + /** + * The workspaces the bot works in. + */ + bot_workspaces: BotWorkspace[]; +}; + +type Status = { error?: boolean; success?: boolean; msg?: string }; + +/** + * Where the bot works. A worker can be added to or taken out of a + * workspace; the coordinator works in every one of them. + */ +export default function BotWorkspacesGroup({ + bot, + workspaces, + bot_workspaces: initial, +}: Props) { + const [bot_workspaces, setBotWorkspaces] = useState(initial); + const [busy_id, setBusyId] = useState(); + const [status, setStatus] = useState(); + const is_coordinator = bot.kind == "coordinator"; + + const addable = workspaces.filter( + (workspace) => + !bot_workspaces.some((member) => member.id == workspace.id), + ); + + const change = async ({ + path, + workspace_id, + }: { + path: "add-bot" | "remove-bot"; + workspace_id: number; + }) => { + setBusyId(path == "add-bot" ? "add" : workspace_id); + setStatus(undefined); + + const res = await postAdminApi({ + path: `workspaces/${path}`, + body: { workspace_id, bot_id: bot.id }, + }); + + setBusyId(undefined); + + if (!res.success) { + setStatus({ error: true, msg: res.msg || "That didn't work." }); + return; + } + + if (path == "add-bot") { + const workspace = workspaces.find( + (item) => Number(item.id) == workspace_id, + ); + + if (workspace) { + setBotWorkspaces((prev) => [ + ...prev, + { + ...workspace, + workspace_system_prompt: null, + workspace_memory: null, + }, + ]); + } + } else { + setBotWorkspaces((prev) => + prev.filter((workspace) => Number(workspace.id) != workspace_id), + ); + } + + setStatus({ success: true, msg: res.msg || "Saved." }); + }; + + return ( + + + {bot_workspaces.map((workspace) => ( + + change({ + path: "remove-bot", + workspace_id: Number(workspace.id), + }) + } + /> + ))} + + {is_coordinator ? null : ( + ({ + value: String(workspace.id), + title: + workspace.name || + `Workspace ${workspace.id}`, + }))} + loading={busy_id == "add"} + onAdd={({ value }) => + change({ + path: "add-bot", + workspace_id: Number(value), + }) + } + /> + )} + + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx new file mode 100644 index 0000000..e6b5f0a --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx @@ -0,0 +1,76 @@ +import { useContext, useState } from "react"; +import { AppContext } from "@/src/pages/__root"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import type { BotWithLastMessage } from "@/src/types"; +import BotHarnessForm from "../../../(partials)/bot-harness-form"; +import SettingsSectionHeading from "../../../(partials)/settings-section-heading"; +import SettingsShell from "../../../(partials)/settings-shell"; +import BotComputersGroup from "../(partials)/bot-computers-group"; +import BotDeleteGroup from "../(partials)/bot-delete-group"; +import BotDetailsGroup from "../(partials)/bot-details-group"; +import BotOverviewGroup from "../(partials)/bot-overview-group"; +import BotWorkspacesGroup from "../(partials)/bot-workspaces-group"; + +/** + * One bot's settings: overview, details, models, workspaces, computers, + * and deleting a worker. The same page shows the bot's information and + * edits it. + */ +export default function BotSettingsSection() { + const { pageProps } = useContext(AppContext); + const [bot, setBot] = useState( + pageProps?.bot || undefined, + ); + + if (!bot?.id) return null; + + const name = bot.name || `Bot ${bot.id}`; + const is_coordinator = bot.kind == "coordinator"; + const harnesses = pageProps?.bot_harness_overview?.harnesses; + + return ( + + + + setBot((prev) => ({ ...(prev || bot), ...saved })) + } + /> + {harnesses?.length ? ( + <> + + + + ) : null} + + + {is_coordinator ? null : } + + ); +} diff --git a/src/pages/admin/settings/bots/[bot_id]/index.server.ts b/src/pages/admin/settings/bots/[bot_id]/index.server.ts new file mode 100644 index 0000000..3381d8e --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/index.server.ts @@ -0,0 +1,70 @@ +import type { PagePropsType, PageQueryObject, ComputerWithStatus } from "@/src/types"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; +import grabBots from "@/src/functions/backend/db/bots/grab-bots"; +import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot"; +import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers"; +import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers"; +import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview"; +import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview"; +import grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces"; +import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; +import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props"; + +/** + * One bot's settings: its details, models, workspaces and computers. For + * super admins and admins; the coordinator is the super admin's alone. + */ +export const server: BunextPageServerFn = async (params) => { + const settings = await grabCrewSettingsServerProps(params); + + if (!settings.props?.person?.id) return settings; + + const query: PageQueryObject = params.query; + const user_id = settings.props.person.id; + + const bot = await grabUserBot({ bot_id: String(query.bot_id), user_id }); + + if (!bot?.id) { + return { redirect: { destination: "/admin/settings/bots", permanent: false } }; + } + + const [ + bots, + workspaces, + bot_workspaces, + all_computers, + bot_computer_rows, + bot_harness_overview, + ] = await Promise.all([ + grabBots({ user_id }), + grabUserWorkspaces({ user_id }), + grabBotWorkspaces({ user_id, bot }), + grabUserComputers({ user_id }), + grabBotComputers({ bot_id: bot.id }), + (bot.kind == "coordinator" + ? grabHarnessOverview().then(({ harnesses }) => ({ harnesses })) + : grabBotHarnessOverview({ bot_id: bot.id }) + ).catch(() => null), + ]); + + const bot_computers = bot_computer_rows + .map((row) => + all_computers.find((computer) => computer.id == row.id), + ) + .filter((computer): computer is ComputerWithStatus => + Boolean(computer?.id), + ); + + return { + props: { + ...settings.props, + bot, + bots, + workspaces, + bot_workspaces, + bot_computers, + computers: all_computers, + bot_harness_overview, + }, + }; +}; diff --git a/src/pages/admin/settings/bots/[bot_id]/index.tsx b/src/pages/admin/settings/bots/[bot_id]/index.tsx new file mode 100644 index 0000000..e7b7b11 --- /dev/null +++ b/src/pages/admin/settings/bots/[bot_id]/index.tsx @@ -0,0 +1,8 @@ +import BotSettingsSection from "./(sections)/bot-settings-section"; +import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta"; + +export default function AdminSettingsBotPage() { + return ; +} + +export const meta = settingsRecordPageMeta({ kind: "bots" }); diff --git a/src/pages/admin/settings/users/(partials)/new-user-modal.tsx b/src/pages/admin/settings/users/(partials)/new-user-modal.tsx new file mode 100644 index 0000000..5c92572 --- /dev/null +++ b/src/pages/admin/settings/users/(partials)/new-user-modal.tsx @@ -0,0 +1,25 @@ +import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell"; +import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; +import UserForm from "@/src/pages/admin/people/create/(partials)/user-form"; + +type Props = { + open: boolean; + onClose: () => void; + onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void; +}; + +/** + * Adds a user in a modal. Editing one happens on its settings page. + */ +export default function NewUserModal({ open, onClose, onSaved }: Props) { + return ( + !next && onClose()} + title="Add a user" + description="They get their own coordinator and main computer, and sign in with this password." + > + {open ? : null} + + ); +} diff --git a/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx b/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx deleted file mode 100644 index 251c311..0000000 --- a/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell"; -import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; -import UserForm from "@/src/pages/admin/people/create/(partials)/user-form"; - -type Props = { - /** - * Edits this user; adds one when not given. - */ - person?: BUN_SQLITE_COBBLE_USERS_JOIN; - open: boolean; - onClose: () => void; - onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void; -}; - -/** - * Adds or edits a user in a modal. - */ -export default function UserEditModal({ person, open, onClose, onSaved }: Props) { - return ( - !next && onClose()} - title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"} - description={ - person - ? "Leave the password empty to keep theirs." - : "They get their own coordinator and main computer, and sign in with this password." - } - > - {open ? : null} - - ); -} diff --git a/src/pages/admin/settings/users/(partials)/user-settings-row.tsx b/src/pages/admin/settings/users/(partials)/user-settings-row.tsx index 17c9295..574bfd8 100644 --- a/src/pages/admin/settings/users/(partials)/user-settings-row.tsx +++ b/src/pages/admin/settings/users/(partials)/user-settings-row.tsx @@ -4,7 +4,6 @@ import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db"; import parseUserTypes from "@/src/utils/parse-user-types"; import canRevokeUser from "@/src/utils/rbac/can-revoke-user"; -import canUpdateUser from "@/src/utils/rbac/can-update-user"; import SettingsListRow from "../../(partials)/settings-list-row"; import SettingsRowAction from "../../(partials)/settings-row-action"; @@ -13,22 +12,20 @@ type Props = { actor_user_id?: number | string; actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null; busy?: boolean; - onEdit: () => void; onRevoke: () => void; onDelete: () => void; }; /** - * One user: their photo, name, user types and email, then Edit, Revoke - * (not once revoked) and Delete where the actor may (`canUpdateUser`, - * `canRevokeUser`; never revoking or deleting yourself). + * One user: their photo, name, user types and email. The title opens + * their settings page; Revoke (not once revoked) and Delete sit at the + * end where the actor may (`canRevokeUser`; never yourself). */ export default function UserSettingsRow({ person, actor_user_id, actor_user_types, busy, - onEdit, onRevoke, onDelete, }: Props) { @@ -42,25 +39,17 @@ export default function UserSettingsRow({ .join(", "); const is_self = person.id == actor_user_id; const target_user_types = user_types.map((user_type) => ({ user_type })); - const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types }); const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types }); const can_revoke = can_delete && !user_types.includes("revoked"); return ( } + href={`/admin/settings/users/${person.id}`} title={name} badge={is_self ? "You" : undefined} subtitle={[types_title, person.email].filter(Boolean).join(" · ")} > - {can_edit ? ( - - ) : null} {can_revoke ? ( (); + const [creating, setCreating] = useState(false); const savePerson = (person: BUN_SQLITE_COBBLE_USERS_JOIN) => setItems((prev) => @@ -66,7 +61,6 @@ export default function UsersSettingsSection() { actor_user_id={pageProps?.user?.id} actor_user_types={pageProps?.user_types} busy={busy} - onEdit={() => setEditing(person)} onRevoke={() => revoke(person)} onDelete={() => remove({ @@ -80,27 +74,26 @@ export default function UsersSettingsSection() { ))} {canCreateUser({ user_types: pageProps?.user_types }) ? ( - + ) : null} - setEditing(undefined)} + setCreating(false)} onSaved={({ person, msg }) => { savePerson(person); - setEditing(undefined); + setCreating(false); setStatus({ success: true, msg }); }} /> diff --git a/src/pages/admin/settings/users/[user_id]/(partials)/user-danger-group.tsx b/src/pages/admin/settings/users/[user_id]/(partials)/user-danger-group.tsx new file mode 100644 index 0000000..5f5c0c3 --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-danger-group.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import Button from "@moduletrace/twui/layout/Button"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db"; +import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import parseUserTypes from "@/src/utils/parse-user-types"; +import canRevokeUser from "@/src/utils/rbac/can-revoke-user"; +import revokeUser from "../../(functions)/revoke-user"; +import SettingsGroup from "../../../(partials)/settings-group"; +import SettingsStatus from "../../../(partials)/settings-status"; + +type Props = { + person: BUN_SQLITE_COBBLE_USERS_JOIN; + actor_user_id?: number | string; + actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null; + onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void; +}; + +type Status = { error?: boolean; success?: boolean; msg?: string }; + +/** + * Revoking or deleting a user, where the actor may. Never yourself, and + * never the super admin. + */ +export default function UserDangerGroup({ + person, + actor_user_id, + actor_user_types, + onUpdated, +}: Props) { + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(); + const name = person.first_name || "this user"; + const user_types = parseUserTypes({ user_types: person.user_types }); + const is_self = person.id == actor_user_id; + const target_user_types = user_types.map((user_type) => ({ user_type })); + const can_delete = + !is_self && + canRevokeUser({ user_types: actor_user_types, target_user_types }); + const can_revoke = can_delete && !user_types.includes("revoked"); + + const revoke = async () => { + if ( + busy || + !window.confirm( + `Revoke ${name}? They can't sign in until Revoked is removed from their user types. Their crew and files are kept.`, + ) + ) + return; + + setBusy(true); + setStatus(undefined); + + const res = await revokeUser({ person }); + + setBusy(false); + + if (!res.success || !res.singleRes?.id) { + setStatus({ error: true, msg: res.msg || "Couldn't revoke them." }); + return; + } + + onUpdated(res.singleRes); + setStatus({ success: true, msg: `${name} can't sign in now.` }); + }; + + const remove = async () => { + if ( + busy || + !window.confirm( + `Delete ${name} with their bots, workspaces and computers? Their computers' files stay on disk.`, + ) + ) + return; + + setBusy(true); + setStatus(undefined); + + const res = await postAdminApi({ + path: "users/delete-user", + body: { user_id: person.id }, + }); + + if (res.success) { + window.location.href = "/admin/settings/users"; + return; + } + + setBusy(false); + setStatus({ error: true, msg: res.msg || "Couldn't delete them." }); + }; + + return ( + + {is_self ? ( + + This is you. You can't revoke or delete yourself here. + + ) : !can_delete ? ( + + Nothing can be done here. + + ) : ( + + {can_revoke ? ( + + ) : 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 new file mode 100644 index 0000000..7f4d6cc --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-edit-group.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; +import UserForm from "@/src/pages/admin/people/create/(partials)/user-form"; +import SettingsGroup from "../../../(partials)/settings-group"; +import SettingsStatus from "../../../(partials)/settings-status"; + +type Props = { + person: BUN_SQLITE_COBBLE_USERS_JOIN; + onSaved: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void; +}; + +/** + * The user's profile, photo, user types, access and password, saved + * together. The same form as adding one, embedded on the page. + */ +export default function UserEditGroup({ person, onSaved }: Props) { + const [status, setStatus] = useState<{ success?: boolean; msg?: string }>(); + + return ( + + { + onSaved(saved); + setStatus({ success: true, msg }); + }} + /> + + + ); +} diff --git a/src/pages/admin/settings/users/[user_id]/(partials)/user-overview-group.tsx b/src/pages/admin/settings/users/[user_id]/(partials)/user-overview-group.tsx new file mode 100644 index 0000000..229d89b --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(partials)/user-overview-group.tsx @@ -0,0 +1,98 @@ +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import UserAvatar from "@/src/components/general/user-avatar"; +import { UserTypes } from "@/src/dict/user-types-dict"; +import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; +import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; +import parseIdList from "@/src/utils/parse-id-list"; +import parseUserTypes from "@/src/utils/parse-user-types"; +import SettingsGroup from "../../../(partials)/settings-group"; + +type Props = { + person: BUN_SQLITE_COBBLE_USERS_JOIN; + /** + * The session user, to say "You". + */ + actor_user_id?: number | string; + /** + * Workers and workspaces a member reaches. + */ + crew_bots: BUN_SQLITE_COBBLE_BOTS[]; + crew_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[]; +}; + +/** + * A user at a glance: their photo, name, contact details, user types and + * what they reach. + */ +export default function UserOverviewGroup({ + person, + actor_user_id, + crew_bots, + crew_workspaces, +}: Props) { + const name = + [person.first_name, person.last_name].filter(Boolean).join(" ") || + person.email || + `User ${person.id}`; + const user_types = parseUserTypes({ user_types: person.user_types }); + const is_self = person.id == actor_user_id; + const reaches_everything = + user_types.includes("super_admin") || user_types.includes("admin"); + const bot_ids = parseIdList({ ids: person.bot_ids }); + const workspace_ids = parseIdList({ ids: person.workspace_ids }); + const bot_names = crew_bots + .filter((bot) => bot_ids.includes(Number(bot.id))) + .map((bot) => bot.name || `Bot ${bot.id}`); + const workspace_names = crew_workspaces + .filter((workspace) => workspace_ids.includes(Number(workspace.id))) + .map((workspace) => workspace.name || `Workspace ${workspace.id}`); + const access = reaches_everything + ? "Every worker and workspace." + : [ + bot_names.length + ? `Bots: ${bot_names.join(", ")}` + : "No bots.", + workspace_names.length + ? `Workspaces: ${workspace_names.join(", ")}` + : "No workspaces.", + ].join(" "); + + return ( + + + + + + + {name} + + {is_self ? ( + + You + + ) : null} + + + {[person.email, person.username].filter(Boolean).join(" · ")} + + + {UserTypes.filter((type) => + user_types.includes(type.value), + ) + .map((type) => type.title) + .join(", ") || "No user types"} + + + {access} + + + + + ); +} diff --git a/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx b/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx new file mode 100644 index 0000000..c5fe50c --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx @@ -0,0 +1,57 @@ +import { useContext, useState } from "react"; +import { AppContext } from "@/src/pages/__root"; +import { UserTypes } from "@/src/dict/user-types-dict"; +import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; +import parseUserTypes from "@/src/utils/parse-user-types"; +import SettingsShell from "../../../(partials)/settings-shell"; +import UserDangerGroup from "../(partials)/user-danger-group"; +import UserEditGroup from "../(partials)/user-edit-group"; +import UserOverviewGroup from "../(partials)/user-overview-group"; + +/** + * One user's settings: overview, the form that edits them, and revoking + * or deleting them where the actor may. + */ +export default function UserSettingsSection() { + const { pageProps } = useContext(AppContext); + const [person, setPerson] = useState< + BUN_SQLITE_COBBLE_USERS_JOIN | undefined + >(pageProps?.target_person || undefined); + + if (!person?.id) return null; + + const name = + [person.first_name, person.last_name].filter(Boolean).join(" ") || + person.email || + `User ${person.id}`; + const user_types = parseUserTypes({ user_types: person.user_types }); + const types_title = UserTypes.filter((type) => + user_types.includes(type.value), + ) + .map((type) => type.title) + .join(", "); + + return ( + + + + + + ); +} diff --git a/src/pages/admin/settings/users/[user_id]/index.server.ts b/src/pages/admin/settings/users/[user_id]/index.server.ts new file mode 100644 index 0000000..0c912b6 --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/index.server.ts @@ -0,0 +1,42 @@ +import type { PagePropsType, PageQueryObject } from "@/src/types"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; +import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options"; +import grabUsers from "@/src/functions/backend/db/users/grab-users"; +import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio"; +import canListUsers from "@/src/utils/rbac/can-list-users"; +import grabSettingsServerProps from "../../(functions)/grab-settings-server-props"; + +/** + * One user's settings, for those who may list users (`canListUsers`): + * their profile, user types, access and danger zone. + */ +export const server: BunextPageServerFn = async (params) => { + const settings = await grabSettingsServerProps(params); + + if (!settings.props) return settings; + + const { user_types } = await userAuth({ req: params.req }); + + if (!canListUsers({ user_types })) { + return { redirect: { destination: SettingsDefaultPath, permanent: false } }; + } + + const query: PageQueryObject = params.query; + const target_person = ( + await grabUsers({ user_id: String(query.user_id) }) + ).singleRes; + + if (!target_person?.id) { + return { redirect: { destination: "/admin/settings/users", permanent: false } }; + } + + return { + props: { + ...settings.props, + target_person: sanitizeUserBio({ user: target_person }), + ...(await grabCrewOptions()), + }, + }; +}; diff --git a/src/pages/admin/settings/users/[user_id]/index.tsx b/src/pages/admin/settings/users/[user_id]/index.tsx new file mode 100644 index 0000000..954b397 --- /dev/null +++ b/src/pages/admin/settings/users/[user_id]/index.tsx @@ -0,0 +1,8 @@ +import UserSettingsSection from "./(sections)/user-settings-section"; +import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta"; + +export default function AdminSettingsUserPage() { + return ; +} + +export const meta = settingsRecordPageMeta({ kind: "users" }); diff --git a/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx b/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx index 752162a..a18862d 100644 --- a/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx +++ b/src/pages/admin/settings/workspaces/(partials)/workspace-settings-row.tsx @@ -19,10 +19,10 @@ export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Prop return ( } + href={`/admin/settings/workspaces/${workspace.id}`} title={name} subtitle={workspace.description || undefined} > - ); diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx similarity index 100% rename from src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx rename to src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx similarity index 54% rename from src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx rename to src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx index 72103db..9baf5a0 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx @@ -3,41 +3,85 @@ import Link from "@moduletrace/twui/layout/Link"; import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; -import { NotebookPen, X } from "lucide-react"; +import { MessageSquare, NotebookPen, X } from "lucide-react"; import BotAvatar from "@/src/components/general/bot-avatar"; import type { BotFace, BotHue } from "@/src/dict/bot-dict"; import type { WorkspaceBot } from "@/src/types"; type Props = { bot: WorkspaceBot; - href: string; + /** + * The bot's settings page; left out for the coordinator, which only + * super admins manage. + */ + settings_href?: string; + /** + * Its conversation here. + */ + chat_href: string; removing?: boolean; onEdit: () => void; onRemove: () => void; }; /** - * A bot in the workspace: opens its conversation here, edits what it has - * for this workspace alone, or takes it out. + * A bot in the workspace: opens its settings (or, for the coordinator, + * its conversation here), chats with it here, edits what it has for this + * workspace alone, or takes it out. */ -export default function WorkspaceBotRow({ bot, href, removing, onEdit, onRemove }: Props) { +export default function WorkspaceBotRow({ + bot, + settings_href, + chat_href, + removing, + onEdit, + onRemove, +}: Props) { const name = bot.name || "This bot"; - const has_own = Boolean(bot.workspace_system_prompt || bot.workspace_memory); + const has_own = Boolean( + bot.workspace_system_prompt || bot.workspace_memory, + ); + const title_href = settings_href || chat_href; return ( - + - {name} + + {name} + - {has_own ? "Has instructions for this workspace" : bot.role || "Uses its own instructions here"} + {has_own + ? "Has instructions for this workspace" + : bot.role || "Uses its own instructions here"} + {settings_href ? ( + + ) : null}