Share one crew across the install, keep user memory per user
Bots, workspaces and computers now belong to the install. Users reach workers only through bot_users (assigned by a super admin or admin) and workspace_users; the coordinator and main computer are one per install, and only the super admin sees the coordinator. Conversations are per user (bot_sessions.user_id), jobs run for the session's user, and live updates go only to who should see them. A bot's core memory is now general knowledge shared by every user it works for; what it learns about a user lives in bot_user_memories. One-off scripts move existing installs over: move-bot-memory-to-owners (run first), then share-crew. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
|
||||
/**
|
||||
* Shown to a user who reaches no bot yet: an admin assigns them bots or
|
||||
* adds them to a workspace.
|
||||
*/
|
||||
export default function NoBotsSection() {
|
||||
return (
|
||||
<SettingsShell title="Bots" description="The bots you chat with.">
|
||||
<EmptyContent title="No bots for you yet. Ask an admin to assign you a bot or add you to a workspace." />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,10 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest step of the open bot's active task (a `bots_queue_updates`
|
||||
* row's text), kept current from `server:bot-job-update`. Starts over when
|
||||
* `server:bot-updated` brings a different task, or none.
|
||||
* The open session's active task and its latest step (a
|
||||
* `bots_queue_updates` row's text), kept current from
|
||||
* `server:bot-job-update`. Starts over when `server:bot-updated` brings a
|
||||
* different task, or none.
|
||||
*/
|
||||
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
|
||||
@@ -49,11 +50,15 @@ export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
onData: (data: BotUpdatedData) => {
|
||||
if (data.id != bot_id) return;
|
||||
|
||||
setProgress((prev) =>
|
||||
(data.task?.id || null) == prev.task_id ? prev : fromTask({ task: data.task }),
|
||||
);
|
||||
/**
|
||||
* The user's active task with the bot may be in another of
|
||||
* their conversations with it.
|
||||
*/
|
||||
const task = data.task && (!session_id || data.task.session_id == session_id) ? data.task : null;
|
||||
|
||||
setProgress((prev) => ((task?.id || null) == prev.task_id ? prev : fromTask({ task })));
|
||||
},
|
||||
});
|
||||
|
||||
return progress.text;
|
||||
return progress;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CpuAi } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
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 HeaderIconButton from "./header-icon-button";
|
||||
@@ -24,8 +25,9 @@ type Props = {
|
||||
/**
|
||||
* The header's model icon: opens every model the bot uses (text, media
|
||||
* reading, transcription, speech, images) in the Models settings form. On
|
||||
* the coordinator that form is the defaults, for super admins only;
|
||||
* everyone else gets the icon disabled, its title saying why.
|
||||
* the coordinator that form is the defaults, for super admins only; a
|
||||
* worker's (or a workspace's) models are for super admins and admins.
|
||||
* Everyone else gets the icon disabled, its title saying why.
|
||||
*/
|
||||
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -35,9 +37,9 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
|
||||
|
||||
const in_workspace = Boolean(workspace?.id);
|
||||
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
|
||||
const can_edit =
|
||||
!is_coordinator ||
|
||||
isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
const can_edit = is_coordinator
|
||||
? isPassThroughUser({ user_types: pageProps?.user_types })
|
||||
: canManageCrew({ user_types: pageProps?.user_types });
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
const target = (
|
||||
@@ -50,7 +52,9 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
|
||||
: is_coordinator
|
||||
? "Change the default models"
|
||||
: `Change ${name}'s models`
|
||||
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
|
||||
: is_coordinator
|
||||
? `${name} uses the default models. A super admin can change them in Settings, under Models.`
|
||||
: `An admin can change the models ${name} uses.`
|
||||
}
|
||||
disabled={!can_edit}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
@@ -8,6 +8,8 @@ import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -15,12 +17,27 @@ type Props = {
|
||||
|
||||
/**
|
||||
* The header's avatar: opens the bot's profile (picture, name, job,
|
||||
* colour, instructions) to edit.
|
||||
* colour, instructions) to edit, for super admins and admins. Everyone
|
||||
* else sees the avatar alone.
|
||||
*/
|
||||
export default function BotProfileModal({ bot }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
const avatar = (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!canManageCrew({ user_types: pageProps?.user_types })) {
|
||||
return <Stack className="shrink-0">{avatar}</Stack>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
@@ -32,12 +49,7 @@ export default function BotProfileModal({ bot }: Props) {
|
||||
aria-label={`Edit ${name}`}
|
||||
className="shrink-0 p-0! rounded-default hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
{avatar}
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
|
||||
@@ -12,18 +12,29 @@ type Props = {
|
||||
* The latest step of the bot's active task.
|
||||
*/
|
||||
progress?: string | null;
|
||||
/**
|
||||
* The thread's last message is the user's, so a reply is due here.
|
||||
*/
|
||||
awaiting_reply?: boolean;
|
||||
/**
|
||||
* This session has an active task.
|
||||
*/
|
||||
has_task?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the last message while the bot is working on a reply or a task
|
||||
* (or waiting for a model to come back), so slow work doesn't look like
|
||||
* none. A task shows its latest step, else its title.
|
||||
* none. A task shows its latest step. The bot's face is shared by
|
||||
* everyone who sees it, so the row only shows for work in this
|
||||
* conversation: a reply while the user has the last word, or its own
|
||||
* task.
|
||||
*/
|
||||
export default function BotThinkingRow({ bot, progress }: Props) {
|
||||
export default function BotThinkingRow({ bot, progress, awaiting_reply, has_task }: Props) {
|
||||
const name = bot.name || "The bot";
|
||||
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
|
||||
const working = bot.face == "working" && !thinking;
|
||||
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
|
||||
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle && Boolean(awaiting_reply);
|
||||
const working = bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task);
|
||||
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
|
||||
|
||||
if (!thinking && !working && !waiting) return null;
|
||||
|
||||
@@ -32,7 +43,7 @@ export default function BotThinkingRow({ bot, progress }: Props) {
|
||||
: working
|
||||
? `${name} is working…`
|
||||
: `${BotWaitingStatusLine}…`;
|
||||
const detail = working ? progress || bot.status_line : null;
|
||||
const detail = working ? progress : null;
|
||||
|
||||
return (
|
||||
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
|
||||
|
||||
@@ -14,6 +14,10 @@ type Props = {
|
||||
* The latest step of the bot's active task, shown while it works.
|
||||
*/
|
||||
progress?: string | null;
|
||||
/**
|
||||
* This session has an active task.
|
||||
*/
|
||||
has_task?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,7 +26,7 @@ type Props = {
|
||||
* While the bot works on a reply or a task, a status row sits under the
|
||||
* last one.
|
||||
*/
|
||||
export default function MessageThread({ bot, messages, progress }: Props) {
|
||||
export default function MessageThread({ bot, messages, progress, has_task }: Props) {
|
||||
const groups = groupMessages({ messages });
|
||||
|
||||
return (
|
||||
@@ -40,7 +44,12 @@ export default function MessageThread({ bot, messages, progress }: Props) {
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
<BotThinkingRow bot={bot} progress={progress} />
|
||||
<BotThinkingRow
|
||||
bot={bot}
|
||||
progress={progress}
|
||||
awaiting_reply={messages[messages.length - 1]?.author == "user"}
|
||||
has_task={has_task}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,8 @@ export default function Conversation() {
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
messages={messages}
|
||||
progress={progress}
|
||||
progress={progress.text}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
/>
|
||||
<Composer
|
||||
bot={bot}
|
||||
|
||||
@@ -3,15 +3,17 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A worker bot's model settings. The coordinator always uses the defaults,
|
||||
* so its page goes back to its conversation.
|
||||
* A worker bot's model settings, for super admins and admins. The
|
||||
* coordinator always uses the defaults, so its page (and anyone else's)
|
||||
* goes back to the conversation.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
const { user, user_types } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
@@ -24,7 +26,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
return { redirect: { destination: "/admin/bots" } };
|
||||
}
|
||||
|
||||
if (bot.kind == "coordinator") {
|
||||
if (bot.kind == "coordinator" || !canManageCrew({ user_types })) {
|
||||
return { redirect: { destination: `/admin/bots/${bot.id}` } };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import crewRole from "@/src/utils/rbac/crew-role";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Opens the coordinator's conversation, creating the coordinator and the
|
||||
* main computer first if this account doesn't have them yet.
|
||||
* Opens a conversation. The super admin lands on the coordinator, which
|
||||
* (with the main computer) is created first if the install has none.
|
||||
* Anyone else lands on the latest bot they chat with, else their first
|
||||
* workspace; with neither, the page says to ask an admin.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const { user } = await userAuth({ req });
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return {
|
||||
@@ -19,12 +24,25 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
|
||||
await ensureMainComputer({ user_id: user.id, coordinator_id });
|
||||
if (crewRole({ user_types }) == "super_admin") {
|
||||
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
|
||||
await ensureMainComputer({ user_id: user.id, coordinator_id });
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/admin/bots/${coordinator_id}`,
|
||||
},
|
||||
};
|
||||
return { redirect: { destination: `/admin/bots/${coordinator_id}` } };
|
||||
}
|
||||
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
grabBots({ user_id: user.id }),
|
||||
grabUserWorkspaces({ user_id: user.id }),
|
||||
]);
|
||||
|
||||
if (bots[0]?.id) {
|
||||
return { redirect: { destination: `/admin/bots/${bots[0].id}` } };
|
||||
}
|
||||
|
||||
if (workspaces[0]?.id) {
|
||||
return { redirect: { destination: `/admin/workspaces/${workspaces[0].id}` } };
|
||||
}
|
||||
|
||||
return { props: { bots, workspaces } };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import NoBotsSection from "./(sections)/no-bots-section";
|
||||
|
||||
/**
|
||||
* Never rendered: `index.server.ts` always redirects to the coordinator.
|
||||
* Only rendered for a user who reaches no bot yet: `index.server.ts`
|
||||
* otherwise redirects to a conversation.
|
||||
*/
|
||||
export default function BotsPage() {
|
||||
return null;
|
||||
return <NoBotsSection />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function submitUserForm({
|
||||
setLoading,
|
||||
setStatus,
|
||||
onSaved,
|
||||
app_context,
|
||||
}: Params) {
|
||||
if (loading) return;
|
||||
|
||||
@@ -44,6 +45,13 @@ export default async function submitUserForm({
|
||||
confirm_password: form.confirm_password,
|
||||
user_types: form.user_types,
|
||||
image: image?.imageBase64Full,
|
||||
/**
|
||||
* Only where the form offers them (`UserFormAccess`), so a
|
||||
* form without them never clears them.
|
||||
*/
|
||||
...(app_context.pageProps?.crew_bots
|
||||
? { bot_ids: form.bot_ids || [], workspace_ids: form.workspace_ids || [] }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import useFormInit from "@/src/hooks/use-form-init";
|
||||
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { UserFormObject } from "@/src/types";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import parseIdList from "@/src/utils/parse-id-list";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import submitUserForm from "../../(functions)/submit-user-form";
|
||||
import UserFormName from "./user-form-name";
|
||||
@@ -11,6 +12,7 @@ import UserFormAction from "./user-form-action";
|
||||
import UserFormEmail from "./user-form-email";
|
||||
import UserFormUsername from "./user-form-username";
|
||||
import UserFormUserTypes from "./user-form-user-types";
|
||||
import UserFormAccess from "./user-form-access";
|
||||
import UserFormImage from "./user-form-image";
|
||||
import UserFormPassword from "./user-form-password";
|
||||
|
||||
@@ -27,8 +29,9 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A user's photo, name, email, username, user types and password, saved
|
||||
* in one go through `users/save-user`.
|
||||
* A user's photo, name, email, username, user types, what they reach
|
||||
* (bots and workspaces) and password, saved in one go through
|
||||
* `users/save-user`.
|
||||
*/
|
||||
export default function UserForm({ person, onSaved, onCancel }: Props) {
|
||||
const init = useFormInit<UserFormObject>({
|
||||
@@ -41,6 +44,8 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
|
||||
user_types: parseUserTypes({ user_types: person.user_types }).filter(
|
||||
(type) => !UneditableUserTypes.includes(type),
|
||||
),
|
||||
bot_ids: parseIdList({ ids: person.bot_ids }),
|
||||
workspace_ids: parseIdList({ ids: person.workspace_ids }),
|
||||
}
|
||||
: undefined,
|
||||
existing_full: person ? { id: person.id } : undefined,
|
||||
@@ -56,6 +61,7 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
|
||||
<UserFormUsername {...init} />
|
||||
</Row>
|
||||
<UserFormUserTypes {...init} person={person} />
|
||||
<UserFormAccess {...init} person={person} />
|
||||
<UserFormPassword {...init} />
|
||||
<UserFormAction {...init} onCancel={onCancel} />
|
||||
</Form>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import type { UserFormObject } from "@/src/types";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
|
||||
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
|
||||
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the workers a user chats with and the workspaces they work in.
|
||||
* Shown where the page carries the crew (`crew_bots`, `crew_workspaces`),
|
||||
* and only for a user who'd reach nothing without it: super admins and
|
||||
* admins reach every worker and workspace anyway.
|
||||
*/
|
||||
export default function UserFormAccess({ setForm, form, person, app_context }: Props) {
|
||||
const bots = app_context.pageProps?.crew_bots;
|
||||
const workspaces = app_context.pageProps?.crew_workspaces;
|
||||
|
||||
if (!bots || !workspaces) return null;
|
||||
|
||||
const types = [...parseUserTypes({ user_types: person?.user_types }), ...(form.user_types || [])];
|
||||
|
||||
if (types.includes("super_admin") || types.includes("admin")) return null;
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-4! items-stretch">
|
||||
<SearchSelect
|
||||
id="user-form-bots"
|
||||
title="Bots"
|
||||
label="Bots"
|
||||
showLabel
|
||||
multiple
|
||||
placeholder="Pick the bots they chat with"
|
||||
options={bots.map((bot) => ({
|
||||
value: String(bot.id),
|
||||
title: bot.role ? `${bot.name} (${bot.role})` : bot.name || `Bot ${bot.id}`,
|
||||
}))}
|
||||
defaultValues={(form.bot_ids || []).map(String)}
|
||||
info="They chat with these in their own chat. They reach no bot until one is picked here or they're in a workspace."
|
||||
changeHandler={(value) => {
|
||||
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
|
||||
setForm((prev) => ({ ...prev, bot_ids: ids }));
|
||||
}}
|
||||
/>
|
||||
<SearchSelect
|
||||
id="user-form-workspaces"
|
||||
title="Workspaces"
|
||||
label="Workspaces"
|
||||
showLabel
|
||||
multiple
|
||||
placeholder="Pick the workspaces they work in"
|
||||
options={workspaces.map((workspace) => ({
|
||||
value: String(workspace.id),
|
||||
title: workspace.name || `Workspace ${workspace.id}`,
|
||||
}))}
|
||||
defaultValues={(form.workspace_ids || []).map(String)}
|
||||
info="They chat with every bot in these workspaces, there."
|
||||
changeHandler={(value) => {
|
||||
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
|
||||
setForm((prev) => ({ ...prev, workspace_ids: ids }));
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { PagePropsType } 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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import grabSettingsServerProps from "./grab-settings-server-props";
|
||||
|
||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||
|
||||
/**
|
||||
* `grabSettingsServerProps` for the pages that manage the crew (bots,
|
||||
* workspaces, computers): super admins and admins only, everyone else
|
||||
* lands on the general settings.
|
||||
*/
|
||||
export default async function grabCrewSettingsServerProps(params: Params) {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
const { user_types } = await userAuth({ req: params.req });
|
||||
|
||||
if (!canManageCrew({ user_types })) {
|
||||
return { redirect: { destination: SettingsDefaultPath, permanent: false }, props: undefined };
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import canListUsers from "@/src/utils/rbac/can-list-users";
|
||||
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
|
||||
/**
|
||||
* For those who may list users (`canListUsers`: super admins, admins);
|
||||
* everyone else lands on the general settings.
|
||||
* everyone else lands on the general settings. Carries every worker and
|
||||
* workspace, to pick what each user reaches.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
@@ -25,6 +27,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
props: {
|
||||
...settings.props,
|
||||
persons: (await grabUsers()).payload || [],
|
||||
...(await grabCrewOptions()),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useContext, useState } from "react";
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
||||
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
|
||||
@@ -11,7 +13,8 @@ import WorkspaceSectionHeading from "../(partials)/workspace-section-heading";
|
||||
|
||||
/**
|
||||
* A workspace's page beside its rail: its context, bots, computers, then
|
||||
* its models, and deleting it.
|
||||
* its models, and deleting it. A member only lands here while it has no
|
||||
* bots yet.
|
||||
*/
|
||||
export default function WorkspaceOverview() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -19,6 +22,14 @@ export default function WorkspaceOverview() {
|
||||
|
||||
if (!workspace?.id) return null;
|
||||
|
||||
if (!canManageCrew({ user_types: pageProps?.user_types })) {
|
||||
return (
|
||||
<SettingsShell title={workspace.name || "Workspace"} description={workspace.description || "A workspace."}>
|
||||
<EmptyContent title="No bots here yet. An admin adds them." />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
const workspace_id = Number(workspace.id);
|
||||
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
|
||||
|
||||
|
||||
@@ -6,16 +6,18 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
|
||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A workspace's page: its details, crew, computers and models, with the
|
||||
* rail showing its crew.
|
||||
* rail showing its crew. Only super admins and admins manage it; a member
|
||||
* opens its first bot instead.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
const { user, user_types } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
@@ -30,6 +32,19 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
|
||||
const workspace_id = Number(workspace.id);
|
||||
|
||||
/**
|
||||
* Members don't manage it: they go straight to its first bot.
|
||||
*/
|
||||
if (!canManageCrew({ user_types })) {
|
||||
const workspace_bots = await grabWorkspaceBots({ user_id: user.id, workspace_id });
|
||||
|
||||
if (workspace_bots[0]?.id) {
|
||||
return { redirect: { destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}` } };
|
||||
}
|
||||
|
||||
return { props: { workspaces, workspace, bots: workspace_bots, workspace_bots } };
|
||||
}
|
||||
|
||||
const [workspace_bots, all_bots, workspace_computers, computers, workspace_harness_overview] =
|
||||
await Promise.all([
|
||||
grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||
|
||||
Reference in New Issue
Block a user