Run computers with resources and health checks, add settings for bots, workspaces, computers and users

- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and
  editable; new resources stop, remove and recreate the container, its home
  dir kept. Every computer container has a docker health check and stays
  "creating" until it passes; only healthy computers are reached, by
  hostname, else by their IP on the network (dev outside docker).
- Docker is required: the app creates the cobble network on startup and
  exits without docker.
- Production container: deploy/run-app-container.sh mounts .env, the data
  dir and the docker socket; the database (DB_DIR), media, computers' homes
  and bun-sqlite's live schema now live on the data volume.
- Settings pages for bots, workspaces, computers and users, sharing list
  components; bots can be deleted; users are created with a password and
  confirmation, edited and deleted through RBAC-gated APIs.
- User types trimmed to super_admin, admin, member, read_only and revoked;
  admins manage members. UserAvatar shows profile photos from the user row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 10:26:44 +01:00
co-authored by Claude Opus 5.5
parent 0ce61cdb49
commit f24c0d7e82
91 changed files with 2692 additions and 331 deletions
@@ -0,0 +1,39 @@
import { useState } from "react";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
type Status = { error?: boolean; success?: boolean; msg?: string };
type Params<T extends { id?: number | string | "" }> = {
items: T[];
};
/**
* A settings list's rows with its busy state and one status line, and
* `remove`: confirm, post to an admin API route, drop the row.
*/
export default function useSettingsList<T extends { id?: number | string | "" }>({ items: initial }: Params<T>) {
const [items, setItems] = useState(initial);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const remove = async ({ id, path, body, confirm }: { id: T["id"]; path: string; body: Record<string, unknown>; confirm: string }) => {
if (busy || !window.confirm(confirm)) return;
setBusy(true);
setStatus(undefined);
const res = await postAdminApi({ path, body });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
setItems((prev) => prev.filter((item) => item.id != id));
setStatus({ success: true, msg: res.msg || "Deleted." });
};
return { items, setItems, busy, status, setStatus, remove };
}
@@ -0,0 +1,40 @@
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;
/**
* A word beside the title, e.g. "Default" or "You".
*/
badge?: string;
subtitle?: string;
/**
* Its actions, at the end of the row.
*/
children?: ReactNode;
};
/**
* One row of a settings list: an avatar, a title and one line about it,
* then what can be done with it.
*/
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
return (
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
{avatar}
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{title}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
</Span>
{subtitle ? (
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
) : null}
</Stack>
{children ? <Row className="shrink-0 gap-1! flex-nowrap">{children}</Row> : null}
</Row>
);
}
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
children: ReactNode;
/**
* Shown instead when the list has no rows.
*/
empty?: string;
is_empty?: boolean;
};
/**
* A framed list of rows inside a settings group.
*/
export default function SettingsList({ children, empty, is_empty }: Props) {
return (
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
{children}
{is_empty && empty ? (
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">{empty}</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,33 @@
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
label: string;
/**
* A link instead of a button.
*/
href?: string;
disabled?: boolean;
onClick?: () => void;
};
/**
* One small text action on a list row (Edit, Open, Delete).
*/
export default function SettingsRowAction({ title, label, href, disabled, onClick }: Props) {
return (
<Button
title={title}
type="button"
variant="ghost"
color="gray"
size="small"
href={href}
disabled={disabled}
onClick={onClick}
className="px-2! text-[13px]"
>
{label}
</Button>
);
}
@@ -0,0 +1,32 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
/**
* Open while set.
*/
bot?: BotWithLastMessage;
onClose: () => void;
};
/**
* Edits a bot in a modal, with the same form as adding one (and as the
* chat header's avatar).
*/
export default function BotEditModal({ bot, onClose }: Props) {
return (
<AddModalShell
open={Boolean(bot)}
setOpen={(open) => !open && onClose()}
title={`Edit ${bot?.name || "the bot"}`}
description={
bot?.kind == "coordinator"
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
: "Its core instructions apply everywhere. What it does in one workspace is set on that workspace's page."
}
>
{bot ? <BotForm key={bot.id} bot={bot} onCancel={onClose} /> : null}
</AddModalShell>
);
}
@@ -0,0 +1,44 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
bot: BotWithLastMessage;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One bot: its stone or picture, name and job, then Chat, Edit, and for a
* worker its Models and Delete.
*/
export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
const name = bot.name || `Bot ${bot.id}`;
const is_coordinator = bot.kind == "coordinator";
return (
<SettingsListRow
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
}
title={name}
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
>
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy} onClick={onEdit} />
{is_coordinator ? null : (
<>
<SettingsRowAction title={`${name}'s models`} label="Models" href={`/admin/bots/${bot.id}/models`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</>
)}
</SettingsListRow>
);
}
@@ -0,0 +1,63 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import type { BotWithLastMessage } from "@/src/types";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import BotEditModal from "../(partials)/bot-edit-modal";
import BotSettingsRow from "../(partials)/bot-settings-row";
/**
* The user's crew, coordinator first: chat with a bot, edit it, set its
* models, add one or delete a worker.
*/
export default function BotsSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
const [editing, setEditing] = useState<BotWithLastMessage>();
return (
<SettingsGroup
title="Your crew"
description="The coordinator stays. Deleting a bot removes its conversations, tasks and models."
>
<SettingsList is_empty={!items.length} empty="No bots yet.">
{items.map((bot) => (
<BotSettingsRow
key={bot.id}
bot={bot}
busy={busy}
onEdit={() => setEditing(bot)}
onDelete={() =>
remove({
id: bot.id,
path: "bots/delete-bot",
body: { bot_id: bot.id },
confirm: `Delete ${bot.name || "this bot"}? Its conversations, tasks and models go with it.`,
})
}
/>
))}
</SettingsList>
<Button
title="Add a bot"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "bot" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New bot
</Button>
<SettingsStatus status={status} />
<BotEditModal bot={editing} onClose={() => setEditing(undefined)} />
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
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";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
bots: await grabBots({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import BotsSettingsSection from "./(sections)/bots-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsBotsPage() {
return (
<SettingsShell title="Bots" description="Your crew: the coordinator and the bots it hands work to.">
<BotsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Bots" });
@@ -0,0 +1,30 @@
import AddComputerForm from "@/src/layouts/bots/(partials)/add-computer-form";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { ComputerWithStatus } from "@/src/types";
type Props = {
/**
* Open while set.
*/
computer?: ComputerWithStatus;
onClose: () => void;
onUpdated: (params: { computer: ComputerWithStatus; msg: string }) => void;
};
/**
* Edits a computer in a modal, with the same form as creating one.
*/
export default function ComputerEditModal({ computer, onClose, onUpdated }: Props) {
return (
<AddModalShell
open={Boolean(computer)}
setOpen={(open) => !open && onClose()}
title={`Edit ${computer?.name || "the computer"}`}
description="New CPUs or memory restart it with them. Its files stay."
>
{computer ? (
<AddComputerForm key={computer.id} computer={computer} onCancel={onClose} onUpdated={onUpdated} />
) : null}
</AddModalShell>
);
}
@@ -0,0 +1,45 @@
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;
is_super_admin: boolean;
busy?: boolean;
onEdit: () => void;
onRetry: () => void;
onDelete: () => void;
};
/**
* One computer: its name, state and resources, then (super admins) Edit,
* Retry when its setup failed, and Delete (never the main one).
*/
export default function ComputerSettingsRow({ computer, is_super_admin, busy, onEdit, onRetry, onDelete }: Props) {
const name = computer.name || `Computer ${computer.id}`;
const busy_here = busy || computer.status == "creating";
return (
<SettingsListRow
avatar={<ComputerIconTile />}
title={name}
badge={computer.kind == "main" ? "Main" : undefined}
subtitle={[computerStatusLabel({ computer }), computerResourcesLabel({ computer }), computer.description]
.filter(Boolean)
.join(" · ")}
>
{is_super_admin ? (
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy_here} onClick={onEdit} />
) : null}
{is_super_admin && computer.status == "failed" ? (
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
) : null}
{is_super_admin && computer.kind != "main" ? (
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,103 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import useLiveComputers from "@/src/hooks/use-live-computers";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ComputerWithStatus } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import ComputerEditModal from "../(partials)/computer-edit-modal";
import ComputerSettingsRow from "../(partials)/computer-settings-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every computer the user has, main first, kept live: its state and
* resources. Super admins create, edit (new resources restart it, its
* files stay), retry and delete them.
*/
export default function ComputersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { computers, setComputers } = useLiveComputers({ computers: pageProps?.computers || [], include_new: true });
const [editing, setEditing] = useState<ComputerWithStatus>();
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>();
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
const merge = (fresh: ComputerWithStatus) =>
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
setBusy(true);
setStatus(undefined);
const res = await postAdminApi<ComputerWithStatus>({ path, body: { computer_id } });
setBusy(false);
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
if (res.singleRes) merge(res.singleRes);
else setComputers((prev) => prev.filter((c) => c.id != computer_id));
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
};
return (
<SettingsGroup
title="Your computers"
description="Each one is a sandbox container on this machine. Bots reach them through workspaces or direct access."
>
<SettingsList is_empty={!computers.length} empty="No computers yet.">
{computers.map((computer) => (
<ComputerSettingsRow
key={computer.id}
computer={computer}
is_super_admin={is_super_admin}
busy={busy}
onEdit={() => setEditing(computer)}
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
onDelete={() => {
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
}}
/>
))}
</SettingsList>
{is_super_admin ? (
<Button
title="Create a new computer"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "computer" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New computer
</Button>
) : null}
<SettingsStatus status={status} />
<ComputerEditModal
computer={editing}
onClose={() => setEditing(undefined)}
onUpdated={({ computer, msg }) => {
merge(computer);
setEditing(undefined);
setStatus({ success: true, msg });
}}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
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";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
computers: await grabUserComputers({ user_id: settings.props.person.id }),
},
};
};
@@ -0,0 +1,13 @@
import ComputersSettingsSection from "./(sections)/computers-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsComputersPage() {
return (
<SettingsShell title="Computers" description="The machines your bots work on, and what each one gets.">
<ComputersSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Computers" });
@@ -32,12 +32,7 @@ export default function ProfilePhotoField({
return (
<Row className="w-full gap-4! flex-nowrap items-center">
<UserAvatar
src={preview_src}
initials={initials}
alt="Your photo"
size="lg"
/>
<UserAvatar src={preview_src} initials={initials} alt="Your photo" size="lg" />
<Stack className="gap-2! min-w-0">
<Row className="gap-2!">
<Button
@@ -0,0 +1,33 @@
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 "./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 (
<AddModalShell
open={open}
setOpen={(next) => !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 ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
</AddModalShell>
);
}
@@ -0,0 +1,141 @@
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Row from "@moduletrace/twui/layout/Row";
import { useContext, useState } from "react";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import { AppContext } from "@/src/pages/__root";
import type { UserType } from "@/src/types";
import parseUserTypes from "@/src/utils/parse-user-types";
import UserPasswordFields from "./user-password-fields";
import UserTypesSelect from "./user-types-select";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
onCancel: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* A user's name, email, username, password (twice, to confirm) and user
* types. Uneditable types (`super_admin`) aren't offered, and the super
* admin's types aren't shown at all: they never change here.
*/
export default function UserForm({ person, onCancel, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const current_types = parseUserTypes({ user_types: person?.user_types });
const is_super_admin = current_types.includes("super_admin");
const [first_name, setFirstName] = useState(person?.first_name || "");
const [last_name, setLastName] = useState(person?.last_name || "");
const [email, setEmail] = useState(person?.email || "");
const [username, setUsername] = useState(person?.username || "");
const [password, setPassword] = useState("");
const [confirm_password, setConfirmPassword] = useState("");
const [user_types, setUserTypes] = useState<UserType[]>(
person ? current_types.filter((type) => !UneditableUserTypes.includes(type)) : ["member"],
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
if (password != confirm_password) {
setError("The passwords don't match.");
return;
}
setLoading(true);
setError(undefined);
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
path: "users/save-user",
body: {
user_id: person?.id || null,
first_name,
last_name,
email,
username,
password,
confirm_password,
user_types: is_super_admin ? [] : user_types,
},
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't save the user.");
return;
}
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Row className="w-full gap-4! flex-nowrap items-start">
<Input
id="user-form-first-name"
title="First name"
label="First name"
showLabel
placeholder="Ada"
autoComplete="off"
defaultValue={first_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setFirstName(e.target.value)}
/>
<Input
id="user-form-last-name"
title="Last name"
label="Last name"
showLabel
placeholder="Lovelace"
autoComplete="off"
defaultValue={last_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setLastName(e.target.value)}
/>
</Row>
<Input
id="user-form-email"
title="Email"
label="Email"
showLabel
type="email"
placeholder="[email protected]"
autoComplete="off"
defaultValue={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="user-form-username"
title="Username"
label="Username"
showLabel
placeholder="ada"
autoComplete="off"
defaultValue={username}
info="Lowercase letters, numbers, dots, dashes or underscores."
onChange={(e) => setUsername(e.target.value)}
/>
{is_super_admin ? null : (
<UserTypesSelect actor_user_types={pageProps?.user_types} value={user_types} onChange={setUserTypes} />
)}
<UserPasswordFields optional={Boolean(person)} onPassword={setPassword} onConfirm={setConfirmPassword} />
<AddFormActions
submit_label={person ? "Save" : "Add user"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
);
}
@@ -0,0 +1,50 @@
import Input from "@moduletrace/twui/form/Input";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
type Props = {
/**
* Editing someone: both may stay empty to keep their password.
*/
optional?: boolean;
onPassword: (password: string) => void;
onConfirm: (password: string) => void;
};
/**
* The password section of the user form: the password, then the same
* again to confirm it.
*/
export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) {
return (
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
<Stack className="gap-0.5!">
<Span className="text-sm font-bold leading-5 text-ink">{optional ? "New password" : "Password"}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`At least ${AppData.PasswordMinLength} characters.${optional ? " Leave both empty to keep theirs." : " They sign in with it."}`}
</Span>
</Stack>
<Input
id="user-form-password"
title="Password"
label="Password"
showLabel
type="password"
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
autoComplete="new-password"
onChange={(e) => onPassword(e.target.value)}
/>
<Input
id="user-form-confirm-password"
title="Confirm password"
label="Confirm password"
showLabel
type="password"
placeholder="The same password again"
autoComplete="new-password"
onChange={(e) => onConfirm(e.target.value)}
/>
</Stack>
);
}
@@ -0,0 +1,71 @@
import UserAvatar from "@/src/components/general/user-avatar";
import { UserTypes } from "@/src/dict/user-types-dict";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_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";
type Props = {
person: BUN_SQLITE_COBBLE_USERS_JOIN;
actor_user_id?: number | string;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
busy?: boolean;
onEdit: () => void;
onDelete: () => void;
};
/**
* One user: their photo, name, user types and email, then Edit and
* Delete where the actor may (`canUpdateUser`, `canRevokeUser`; never
* deleting yourself).
*/
export default function UserSettingsRow({
person,
actor_user_id,
actor_user_types,
busy,
onEdit,
onDelete,
}: 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 types_title = UserTypes.filter((type) => user_types.includes(type.value))
.map((type) => type.title)
.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 });
return (
<SettingsListRow
avatar={<UserAvatar person={person} size="md" />}
title={name}
badge={is_self ? "You" : undefined}
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
>
{can_edit ? (
<SettingsRowAction
title={`Edit ${name}`}
label="Edit"
disabled={busy}
onClick={onEdit}
/>
) : null}
{can_delete ? (
<SettingsRowAction
title={`Delete ${name}`}
label="Delete"
disabled={busy}
onClick={onDelete}
/>
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,45 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import type { UserType } from "@/src/types";
import filterAssignableUserTypes from "@/src/utils/rbac/filter-assignable-user-types";
type Props = {
/**
* The session user's types: only what they may grant is offered.
*/
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
value: UserType[];
onChange: (user_types: UserType[]) => void;
};
/**
* Picks a user's types from `UserTypes`, leaving out the uneditable ones
* (`super_admin`) and any the assignment matrix keeps from the actor.
*/
export default function UserTypesSelect({ actor_user_types, value, onChange }: Props) {
const assignable = filterAssignableUserTypes({
actor_user_types,
user_types: UserTypes.map((type) => type.value).filter((type) => !UneditableUserTypes.includes(type)),
});
return (
<SearchSelect
id="user-form-user-types"
title="User types"
label="User types"
showLabel
multiple
placeholder="Pick their user types"
options={UserTypes.filter((type) => assignable.includes(type.value)).map((type) => ({
value: type.value,
title: type.title,
}))}
defaultValues={value}
info="At least one, or they can't sign in."
changeHandler={(next) =>
onChange((Array.isArray(next) ? next : next ? [next] : []) as UserType[])
}
/>
);
}
@@ -0,0 +1,84 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import canCreateUser from "@/src/utils/rbac/can-create-user";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import UserEditModal from "../(partials)/user-edit-modal";
import UserSettingsRow from "../(partials)/user-settings-row";
/**
* Everyone who can sign in (for super admins and admins): add a user
* with a password, edit one (name, email, username, a new password, user
* types), or delete one with everything that's theirs. What each person
* may do here is the RBAC helpers' call.
*/
export default function UsersSettingsSection() {
const { pageProps } = useContext(AppContext);
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({ items: pageProps?.persons || [] });
/**
* The user being edited, or `true` while adding one.
*/
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
return (
<SettingsGroup
title="People"
description="Sign in is by email or username and password, so set one for each new user."
>
<SettingsList is_empty={!items.length} empty="No users yet.">
{items.map((person) => (
<UserSettingsRow
key={person.id}
person={person}
actor_user_id={pageProps?.user?.id}
actor_user_types={pageProps?.user_types}
busy={busy}
onEdit={() => setEditing(person)}
onDelete={() =>
remove({
id: person.id,
path: "users/delete-user",
body: { user_id: person.id },
confirm: `Delete ${person.first_name || "this user"} with their bots, workspaces and computers? Their computers' files stay on disk.`,
})
}
/>
))}
</SettingsList>
{canCreateUser({ user_types: pageProps?.user_types }) ? (
<Button
title="Add a user"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setEditing(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New user
</Button>
) : null}
<SettingsStatus status={status} />
<UserEditModal
person={editing === true ? undefined : editing}
open={Boolean(editing)}
onClose={() => setEditing(undefined)}
onSaved={({ person, msg }) => {
setItems((prev) =>
prev.some((p) => p.id == person.id)
? prev.map((p) => (p.id == person.id ? person : p))
: [...prev, person],
);
setEditing(undefined);
setStatus({ success: true, msg });
}}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,30 @@
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 canListUsers from "@/src/utils/rbac/can-list-users";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
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.
*/
export const server: BunextPageServerFn<PagePropsType> = 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 } };
}
return {
props: {
...settings.props,
persons: (await grabUsers()).payload || [],
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import UsersSettingsSection from "./(sections)/users-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsUsersPage() {
return (
<SettingsShell title="Users" description="Who can sign in to this install. Each has their own crew and computers.">
<UsersSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Users" });
@@ -0,0 +1,29 @@
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import type { WorkspaceWithActivity } from "@/src/types";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
workspace: WorkspaceWithActivity;
busy?: boolean;
onDelete: () => void;
};
/**
* One workspace: its picture, name and what it's about, then Open and
* Delete.
*/
export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Props) {
const name = workspace.name || `Workspace ${workspace.id}`;
return (
<SettingsListRow
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
title={name}
subtitle={workspace.description || undefined}
>
<SettingsRowAction title={`Open ${name}`} label="Open" href={`/admin/workspaces/${workspace.id}`} />
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
</SettingsListRow>
);
}
@@ -0,0 +1,58 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
import useSettingsList from "../../(hooks)/use-settings-list";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import WorkspaceSettingsRow from "../(partials)/workspace-settings-row";
/**
* Every workspace the user has: open one to set it up (its page has its
* context, bots, computers and models), create one, or delete one.
*/
export default function WorkspacesSettingsSection() {
const { pageProps } = useContext(AppContext);
const { openForm } = useContext(AddFlowContext);
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.workspaces || [] });
return (
<SettingsGroup
title="Your workspaces"
description="Bots in a workspace share its context and computers. Deleting one keeps its bots and computers."
>
<SettingsList is_empty={!items.length} empty="No workspaces yet.">
{items.map((workspace) => (
<WorkspaceSettingsRow
key={workspace.id}
workspace={workspace}
busy={busy}
onDelete={() =>
remove({
id: workspace.id,
path: "workspaces/delete-workspace",
body: { workspace_id: workspace.id },
confirm: `Delete ${workspace.name || "this workspace"} and its conversations? Its bots and computers stay.`,
})
}
/>
))}
</SettingsList>
<Button
title="Create a workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => openForm({ kind: "workspace" })}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New workspace
</Button>
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,17 @@
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";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
},
};
};
@@ -0,0 +1,13 @@
import WorkspacesSettingsSection from "./(sections)/workspaces-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsWorkspacesPage() {
return (
<SettingsShell title="Workspaces" description="Projects with their own context, bots, computers and models.">
<WorkspacesSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Workspaces" });