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:
@@ -0,0 +1,51 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deleteBot from "@/src/functions/backend/db/bots/delete-bot";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { BotIdRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes one of the session user's worker bots and everything it had
|
||||
* (conversations, tasks, models). The coordinator stays.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
|
||||
|
||||
const bot = await deleteBot({ user_id: user.id, bot_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Bot deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the bot ${bot.name}.`,
|
||||
metadata: { bot_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${bot.name || "The bot"} is deleted.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -42,13 +42,19 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { name, description, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
|
||||
const { name, description, cpus, memory_mb, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
|
||||
|
||||
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
const { computer_id } = await createComputer({ user_id: user.id, name, description });
|
||||
const { computer_id } = await createComputer({
|
||||
user_id: user.id,
|
||||
name,
|
||||
description,
|
||||
cpus,
|
||||
memory_mb,
|
||||
});
|
||||
|
||||
if (workspace_id) {
|
||||
await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import updateComputer from "@/src/functions/backend/db/computers/update-computer";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import { UpdateComputerRequestSchema } from "@/src/schemas/computer-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Updates a computer of the session user (super admins only: each one is
|
||||
* a container on this host). New resources (`cpus`, `memory_mb`) recreate
|
||||
* its container in the background, its files kept; its state arrives as
|
||||
* `server:computer-updated`. Returns the computer.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
if (!isPassThroughUser({ user_types })) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { computer_id, ...data } = parseWithSchema({ schema: UpdateComputerRequestSchema, data: body });
|
||||
|
||||
const { recreating } = await updateComputer({ user_id: user.id, computer_id, data });
|
||||
|
||||
const computer = (await grabUserComputers({ computer_id }))[0];
|
||||
const name = computer?.name || "The computer";
|
||||
|
||||
await logEvent({
|
||||
title: "Computer updated",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} updated the computer ${name}.`,
|
||||
metadata: { computer_id, ...data, recreating },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: recreating ? `${name} is restarting with its new settings.` : `${name} saved.`,
|
||||
singleRes: recreating ? { ...computer, status: "creating", error: null } : computer,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deleteUser from "@/src/functions/backend/db/users/delete-user";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { UserIdRequestSchema } from "@/src/schemas/user-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes a user and everything that's theirs, when the actor could
|
||||
* revoke them (`canRevokeUser`), never themselves.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { user_id } = parseWithSchema({ schema: UserIdRequestSchema, data: body });
|
||||
const deleted = await deleteUser({ actor_user_id: user.id, actor_user_types: user_types || [], user_id });
|
||||
|
||||
await logEvent({
|
||||
title: "User deleted",
|
||||
category: "auth",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted user #${user_id} (${deleted.email}).`,
|
||||
metadata: { user_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${deleted.first_name || "The user"} is deleted.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
|
||||
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
|
||||
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||
import saveUser from "@/src/functions/backend/db/users/save-user";
|
||||
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import canCreateUser from "@/src/utils/rbac/can-create-user";
|
||||
import canUpdateUser from "@/src/utils/rbac/can-update-user";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
|
||||
* name, email, username, password and user types, each type checked
|
||||
* against the assignment matrix. A new user gets their coordinator and main computer
|
||||
* straight away, as at signup. Returns the user.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const data = parseWithSchema({ schema: SaveUserRequestSchema, data: body });
|
||||
|
||||
const target = data.user_id ? (await grabUsers({ user_id: data.user_id })).singleRes : null;
|
||||
|
||||
const allowed = data.user_id
|
||||
? canUpdateUser({
|
||||
actor_user_types: user_types,
|
||||
actor_user_id: user.id,
|
||||
target_user_id: data.user_id,
|
||||
target_user_types: parseUserTypes({ user_types: target?.user_types }).map((user_type) => ({ user_type })),
|
||||
update_fields: target && target.email != data.email.trim().toLowerCase() ? ["email"] : [],
|
||||
})
|
||||
: canCreateUser({ user_types });
|
||||
|
||||
if (!allowed) {
|
||||
throw new AppError(`You can't do that.`);
|
||||
}
|
||||
const { user_id, created } = await saveUser({ ...data, actor_user_types: user_types || [] });
|
||||
|
||||
if (created) {
|
||||
/**
|
||||
* The account exists now; `/admin/bots` creates both on their
|
||||
* first visit if this fails.
|
||||
*/
|
||||
await ensureCoordinatorBot({ user_id })
|
||||
.then((coordinator_id) => ensureMainComputer({ user_id, coordinator_id }))
|
||||
.catch((error) =>
|
||||
logError({
|
||||
title: "Coordinator or main computer not created with the user",
|
||||
error,
|
||||
user_id,
|
||||
ip_address: grabRequestIP(req),
|
||||
}).catch(() => {}),
|
||||
);
|
||||
}
|
||||
|
||||
await logEvent({
|
||||
title: created ? "User created" : "User updated",
|
||||
category: "auth",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} ${created ? "created" : "updated"} user #${user_id}.`,
|
||||
metadata: { user_id, user_types: data.user_types, password_changed: Boolean(data.password) },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: created ? `${data.first_name} can sign in now.` : `Saved.`,
|
||||
singleRes: (await grabUsers({ user_id })).singleRes,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user