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
+13 -2
View File
@@ -20,8 +20,11 @@ export const MainComputerName = "Main computer";
/**
* Docker container state as reported by `docker inspect` (`State.Status`),
* plus `creating` while the app sets it up, `failed` when that didn't
* work, and `unknown` for a record that hasn't been inspected yet.
* with `running` narrowed by its health check: `starting` until it first
* passes, `unhealthy` once it keeps failing. Plus `creating` while the app
* sets it up (until the health check passes), `failed` when that didn't
* work, and `unknown` for a record that hasn't been inspected yet. Only a
* `running` computer is reached.
*/
export const ContainerStatuses = [
{
@@ -36,10 +39,18 @@ export const ContainerStatuses = [
title: "Created",
value: "created",
},
{
title: "Starting",
value: "starting",
},
{
title: "Running",
value: "running",
},
{
title: "Unhealthy",
value: "unhealthy",
},
{
title: "Paused",
value: "paused",
+1
View File
@@ -70,6 +70,7 @@ export const ProcessOnlyEnvKeys = [
"ENCRYPTION_SALT",
"PORT",
"DATA_DIR",
"DB_DIR",
"MAIN_SERVER",
] as const;
+21
View File
@@ -1,11 +1,17 @@
import {
Bot,
CpuAi,
KeyRound,
Layers,
Monitor,
Users,
SlidersHorizontal,
UserRound,
Variable,
type LucideIcon,
} from "lucide-react";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import canListUsers from "@/src/utils/rbac/can-list-users";
export type SettingsPage = {
title: string;
@@ -15,8 +21,14 @@ export type SettingsPage = {
* Shown and served to super admins only.
*/
super_admin_only?: boolean;
/**
* Who sees it, when that's an RBAC rule rather than super admins only.
*/
can_view?: (params: { user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null }) => boolean;
};
export const SettingsComputersPath = "/admin/settings/computers";
/**
* The `/admin/settings/*` sub pages, listed in the rail on every settings
* page. The first one is where `/admin/settings` lands.
@@ -33,6 +45,15 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/change-password",
Icon: KeyRound,
},
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor },
{
title: "Users",
url: "/admin/settings/users",
Icon: Users,
can_view: canListUsers,
},
{
title: "Models",
url: "/admin/settings/models",
+13 -22
View File
@@ -19,6 +19,7 @@ describe("UserTypes dict", () => {
const required = [
"super_admin",
"admin",
"member",
"revoked",
"read_only",
] as const;
@@ -48,28 +49,18 @@ describe("Assignment matrices", () => {
).toBeUndefined();
});
test("elevated types are pass-through only", () => {
const elevated = [
"admin",
"president",
"vice_president",
"secretary",
] as const;
for (const type of elevated) {
const entry = UserTypeAssignmentMatrix[type];
expect(Array.isArray(entry.allowed_actors)).toBe(true);
expect((entry.allowed_actors as string[]).length).toBe(0);
test("admin is granted by pass-through actors only", () => {
const entry = UserTypeAssignmentMatrix["admin"];
expect(Array.isArray(entry.allowed_actors)).toBe(true);
expect((entry.allowed_actors as string[]).length).toBe(0);
});
test("admin manages member types, never on admins or the super admin", () => {
for (const type of ["member", "read_only", "revoked"] as const) {
for (const matrix of [UserTypeAssignmentMatrix, UserTypeDeletionMatrix]) {
const rules = (matrix[type].allowed_actors as Record<string, { excluded_targets?: string[] }>).admin;
expect(rules?.excluded_targets).toEqual(["super_admin", "admin"]);
}
}
});
test("admin can manage auditor but not another admin", () => {
expect(
UserTypeAssignmentMatrix["auditor"].allowed_actors,
).toContain("admin");
expect(
(UserTypeAssignmentMatrix["admin"].allowed_actors as string[])
.length,
).toBe(0);
});
});
+33 -53
View File
@@ -1,29 +1,33 @@
import type { UserType, UserTypeAssignmentMatrixType } from "../types";
/**
* Every user type on this install. A user needs at least one to sign in.
*/
export const UserTypes = [
{
value: "super_admin",
title: "Super admin",
description: "Runs the install: models, environment, computers and users. The first account.",
},
{
value: "admin",
title: "Admin",
description: "Adds and manages members.",
},
{
value: "auditor",
},
{
value: "president",
},
{
value: "vice_president",
},
{
value: "secretary",
},
{
value: "revoked",
value: "member",
title: "Member",
description: "Has their own crew, workspaces and main computer.",
},
{
value: "read_only",
title: "Read only",
description: "Can look around, but can't change anything.",
},
{
value: "revoked",
title: "Revoked",
description: "Can't sign in. Their crew and files are kept.",
},
] as const;
@@ -58,36 +62,24 @@ export const UserTypeAssignmentMatrix: UserTypeAssignmentMatrixType = {
admin: {
allowed_actors: [],
},
president: {
allowed_actors: [],
},
vice_president: {
allowed_actors: [],
},
secretary: {
allowed_actors: [],
},
auditor: {
allowed_actors: ["admin"],
member: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin", "admin"],
},
},
},
read_only: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin"],
excluded_targets: ["super_admin", "admin"],
},
},
},
revoked: {
allowed_actors: {
admin: {
allowed_targets: ["read_only"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
excluded_targets: ["super_admin", "admin"],
},
},
},
@@ -98,36 +90,24 @@ export const UserTypeDeletionMatrix: UserTypeAssignmentMatrixType = {
admin: {
allowed_actors: [],
},
president: {
allowed_actors: [],
},
vice_president: {
allowed_actors: [],
},
secretary: {
allowed_actors: [],
},
auditor: {
allowed_actors: ["admin"],
member: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin", "admin"],
},
},
},
read_only: {
allowed_actors: {
admin: {
excluded_targets: ["super_admin"],
excluded_targets: ["super_admin", "admin"],
},
},
},
revoked: {
allowed_actors: {
admin: {
allowed_targets: ["read_only"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
excluded_targets: ["super_admin", "admin"],
},
},
},