Link existing bots from the workspace rail's "+"
The "+" beside "Workspace bots" now opens a modal listing the crew's workers not in the workspace yet (api/admin/workspaces/linkable-bots), each added in one click, with "New bot" stacking the bot form over it. Adding a bot to a workspace now pushes server:bot-created so other rails pick it up live. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -160,7 +160,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
|||||||
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
||||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||||
|
|
||||||
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
||||||
|
|
||||||
## Page Directory Rules
|
## Page Directory Rules
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export type AddFlow = {
|
|||||||
* a workspace page's "New computer").
|
* a workspace page's "New computer").
|
||||||
*/
|
*/
|
||||||
openForm: (params: { kind: AddKind }) => void;
|
openForm: (params: { kind: AddKind }) => void;
|
||||||
|
/**
|
||||||
|
* Opens the link-bots modal of the workspace being viewed (the
|
||||||
|
* workspace rail's "+"), whose "New bot" opens the bot form over it.
|
||||||
|
*/
|
||||||
|
openLinkBots: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,4 +24,5 @@ export type AddFlow = {
|
|||||||
export const AddFlowContext = React.createContext<AddFlow>({
|
export const AddFlowContext = React.createContext<AddFlow>({
|
||||||
openAdd: () => {},
|
openAdd: () => {},
|
||||||
openForm: () => {},
|
openForm: () => {},
|
||||||
|
openLinkBots: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AddComputerForm from "./add-computer-form";
|
|||||||
import AddModalShell from "./add-modal-shell";
|
import AddModalShell from "./add-modal-shell";
|
||||||
import AddNotice from "./add-notice";
|
import AddNotice from "./add-notice";
|
||||||
import AddWorkspaceForm from "./add-workspace-form";
|
import AddWorkspaceForm from "./add-workspace-form";
|
||||||
|
import LinkBotsModal from "./link-bots-modal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -25,12 +26,13 @@ type Props = {
|
|||||||
export default function AddFlow({ children }: Props) {
|
export default function AddFlow({ children }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const [choosing, setChoosing] = useState(false);
|
const [choosing, setChoosing] = useState(false);
|
||||||
|
const [linking, setLinking] = useState(false);
|
||||||
const [form, setForm] = useState<AddKind | null>(null);
|
const [form, setForm] = useState<AddKind | null>(null);
|
||||||
const [notice, setNotice] = useState<string>();
|
const [notice, setNotice] = useState<string>();
|
||||||
/**
|
/**
|
||||||
* twui closes every open modal on Escape. While a form is open over the
|
* twui closes every open modal on Escape. While a form is open over the
|
||||||
* Add modal, the Add modal is remounted open instead, so only the form
|
* Add modal (or the link-bots modal), that modal is remounted open
|
||||||
* closes.
|
* instead, so only the form closes.
|
||||||
*/
|
*/
|
||||||
const form_ref = useRef<AddKind | null>(null);
|
const form_ref = useRef<AddKind | null>(null);
|
||||||
const [chooser_key, setChooserKey] = useState(0);
|
const [chooser_key, setChooserKey] = useState(0);
|
||||||
@@ -49,25 +51,32 @@ export default function AddFlow({ children }: Props) {
|
|||||||
setForm(kind);
|
setForm(kind);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a modal the forms open over, unless a form is open on top.
|
||||||
|
*/
|
||||||
|
const underForm = (setter: (open: boolean) => void) => (open: boolean) => {
|
||||||
|
if (!open && form_ref.current) {
|
||||||
|
setChooserKey((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setter(open);
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
form_ref.current = null;
|
form_ref.current = null;
|
||||||
setForm(null);
|
setForm(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AddFlowContext.Provider value={{ openAdd: () => setChoosing(true), openForm }}>
|
<AddFlowContext.Provider
|
||||||
|
value={{ openAdd: () => setChoosing(true), openForm, openLinkBots: () => setLinking(true) }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<AddModalShell
|
<AddModalShell
|
||||||
key={chooser_key}
|
key={chooser_key}
|
||||||
open={choosing}
|
open={choosing}
|
||||||
setOpen={(open) => {
|
setOpen={underForm(setChoosing)}
|
||||||
if (!open && form_ref.current) {
|
|
||||||
setChooserKey((prev) => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setChoosing(open);
|
|
||||||
}}
|
|
||||||
title="Add"
|
title="Add"
|
||||||
description={
|
description={
|
||||||
workspace?.id
|
workspace?.id
|
||||||
@@ -85,6 +94,16 @@ export default function AddFlow({ children }: Props) {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AddModalShell>
|
</AddModalShell>
|
||||||
|
{workspace?.id ? (
|
||||||
|
<LinkBotsModal
|
||||||
|
key={`link-${chooser_key}`}
|
||||||
|
open={linking}
|
||||||
|
setOpen={underForm(setLinking)}
|
||||||
|
workspace_id={Number(workspace.id)}
|
||||||
|
workspace_name={workspace.name}
|
||||||
|
onNewBot={() => openForm({ kind: "bot" })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<AddModalShell
|
<AddModalShell
|
||||||
open={form == "bot"}
|
open={form == "bot"}
|
||||||
setOpen={(open) => !open && close()}
|
setOpen={(open) => !open && close()}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 BotAvatar from "@/src/components/general/bot-avatar";
|
||||||
|
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
linking?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onLink: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bot in the link-bots modal: stone, name, job, and Add.
|
||||||
|
*/
|
||||||
|
export default function LinkBotRow({ bot, linking, disabled, onLink }: Props) {
|
||||||
|
return (
|
||||||
|
<Row className="h-14 items-center gap-3 rounded-default px-3 flex-nowrap hover:bg-sunken transition-colors duration-[var(--m-fast)]">
|
||||||
|
<BotAvatar
|
||||||
|
image_media_id={bot.image_media_id}
|
||||||
|
hue={(bot.hue || null) as BotHue | null}
|
||||||
|
face={(bot.face || "idle") as BotFace}
|
||||||
|
/>
|
||||||
|
<Stack className="grow min-w-0 gap-0!">
|
||||||
|
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{bot.name}</Span>
|
||||||
|
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{bot.role || ""}</Span>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
title={`Add ${bot.name || "this bot"}`}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
loading={linking}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onLink}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import type { BotWithLastMessage, WorkspaceBot } from "@/src/types";
|
||||||
|
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||||
|
import postAdminApi from "../(functions)/post-admin-api";
|
||||||
|
import AddModalShell from "./add-modal-shell";
|
||||||
|
import LinkBotRow from "./link-bot-row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
workspace_id: number;
|
||||||
|
workspace_name?: string | null;
|
||||||
|
/**
|
||||||
|
* Opens the Add-a-bot form over this modal.
|
||||||
|
*/
|
||||||
|
onNewBot: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace rail's "+": the crew's workers not in the workspace yet,
|
||||||
|
* each added with one click, and "New bot" for one that doesn't exist.
|
||||||
|
* Adding reloads the page, so the rail and the dashboard show the new crew.
|
||||||
|
*/
|
||||||
|
export default function LinkBotsModal({ open, setOpen, workspace_id, workspace_name, onNewBot }: Props) {
|
||||||
|
const [bots, setBots] = useState<BotWithLastMessage[]>();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [linking_id, setLinkingId] = useState<number>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
postAdminApi<BotWithLastMessage>({ path: "workspaces/linkable-bots", body: { workspace_id } }).then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
setBots(res.payload || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBots([]);
|
||||||
|
setError(res.msg || "Couldn't load the bots.");
|
||||||
|
});
|
||||||
|
}, [open, workspace_id]);
|
||||||
|
|
||||||
|
const link = async ({ bot }: { bot: BotWithLastMessage }) => {
|
||||||
|
if (!window.confirm(`Add ${bot.name || "this bot"} to ${workspace_name || "this workspace"}?`)) return;
|
||||||
|
|
||||||
|
setLinkingId(Number(bot.id));
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<WorkspaceBot>({
|
||||||
|
path: "workspaces/add-bot",
|
||||||
|
body: { workspace_id, bot_id: bot.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkingId(undefined);
|
||||||
|
setError(res.msg || "That didn't work.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = query.trim().toLowerCase();
|
||||||
|
const found = (bots || []).filter(
|
||||||
|
(bot) => !search || `${bot.name} ${bot.role || ""}`.toLowerCase().includes(search),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={`Add bots to ${workspace_name || "this workspace"}`}
|
||||||
|
description="Bots here work with the workspace's context, computers and models."
|
||||||
|
>
|
||||||
|
{bots?.length ? (
|
||||||
|
<Input
|
||||||
|
id="link-bots-search"
|
||||||
|
title="Search bots"
|
||||||
|
placeholder="Search by name or job"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
wrapperWrapperProps={{ className: "mt-4" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Stack className="w-full mt-2 max-h-[50vh] overflow-y-auto gap-0! items-stretch">
|
||||||
|
{found.map((bot) => (
|
||||||
|
<LinkBotRow
|
||||||
|
key={bot.id}
|
||||||
|
bot={bot}
|
||||||
|
linking={linking_id == bot.id}
|
||||||
|
disabled={Boolean(linking_id)}
|
||||||
|
onLink={() => link({ bot })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!bots || found.length ? null : (
|
||||||
|
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{search ? `No bot matches "${query.trim()}".` : "Every bot is already here."}
|
||||||
|
</Span>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||||
|
<Button
|
||||||
|
title="Create a new bot for this workspace"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
onClick={onNewBot}
|
||||||
|
className="self-start gap-1! mt-3"
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden />
|
||||||
|
New bot
|
||||||
|
</Button>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ type Props = {
|
|||||||
* workspace's context. The coordinator is reached from the main rail.
|
* workspace's context. The coordinator is reached from the main rail.
|
||||||
*/
|
*/
|
||||||
export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) {
|
export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) {
|
||||||
const { openForm } = useContext(AddFlowContext);
|
const { openLinkBots } = useContext(AddFlowContext);
|
||||||
const workers = bots.filter((bot) => bot.kind !== "coordinator");
|
const workers = bots.filter((bot) => bot.kind !== "coordinator");
|
||||||
const base = `/admin/workspaces/${workspace.id}`;
|
const base = `/admin/workspaces/${workspace.id}`;
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
|
|||||||
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||||
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
|
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
|
||||||
<RailBotsSection
|
<RailBotsSection
|
||||||
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
|
action={<RailSectionAddButton title="Add bots to this workspace" onClick={openLinkBots} />}
|
||||||
title="Workspace bots"
|
title="Workspace bots"
|
||||||
workers={workers}
|
workers={workers}
|
||||||
selected_id={on_workspace_page ? undefined : selected_id}
|
selected_id={on_workspace_page ? undefined : selected_id}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
||||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||||
@@ -14,7 +17,8 @@ import type {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts one of the session user's bots in one of their workspaces. Returns
|
* Puts one of the session user's bots in one of their workspaces. Returns
|
||||||
* the workspace's crew.
|
* the workspace's crew, and pushes the bot to everyone who now sees it
|
||||||
|
* there.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = async ({ req, body }) => {
|
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = async ({ req, body }) => {
|
||||||
try {
|
try {
|
||||||
@@ -36,6 +40,15 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = a
|
|||||||
|
|
||||||
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
|
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
|
||||||
|
|
||||||
|
const bot = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`,
|
||||||
|
values: [bot_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (bot) await pushBotCreated({ bot });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||||
|
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||||
|
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
import { WorkspaceIdRequestSchema } from "@/src/schemas/workspace-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The crew's workers that aren't in a workspace yet, for the workspace
|
||||||
|
* rail's "+". Super admins and admins only.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BotWithLastMessage>> = async ({ req, body }) => {
|
||||||
|
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 (!canManageCrew({ user_types })) {
|
||||||
|
throw new AppError(`Only admins add bots to workspaces.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace_id } = parseWithSchema({ schema: WorkspaceIdRequestSchema, data: body });
|
||||||
|
|
||||||
|
const workspace = await grabUserWorkspace({ workspace_id, user_id: user.id });
|
||||||
|
|
||||||
|
if (!workspace?.id) {
|
||||||
|
throw new AppError(`Workspace not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bots, members] = await Promise.all([
|
||||||
|
grabBots({ user_id: user.id }),
|
||||||
|
grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
payload: bots.filter(
|
||||||
|
(bot) => bot.kind != "coordinator" && !members.some((member) => member.id == bot.id),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user