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,98 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
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 grabUsers from "./grab-users";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Who is deleting: never themselves.
|
||||
*/
|
||||
actor_user_id: number | string;
|
||||
/**
|
||||
* Whoever may revoke a user may delete them (`canRevokeUser`).
|
||||
*/
|
||||
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a user with everything that's theirs (bots, workspaces,
|
||||
* conversations, computers, user types, password, media and its files):
|
||||
* their computers' containers are removed first, their home directories
|
||||
* stay on disk. Their audit log entries stay too. The super admin can't be
|
||||
* deleted, and nobody deletes themselves.
|
||||
*/
|
||||
export default async function deleteUser({ actor_user_id, actor_user_types, user_id }: Params) {
|
||||
if (String(actor_user_id) == String(user_id)) {
|
||||
throw new AppError(`You can't delete yourself.`);
|
||||
}
|
||||
|
||||
const user = (await grabUsers({ user_id })).singleRes;
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`User not found.`);
|
||||
}
|
||||
|
||||
if (parseUserTypes({ user_types: user.user_types }).includes("super_admin")) {
|
||||
throw new AppError(`The super admin can't be deleted.`);
|
||||
}
|
||||
|
||||
const target_user_types = parseUserTypes({ user_types: user.user_types }).map((user_type) => ({ user_type }));
|
||||
|
||||
if (!canRevokeUser({ user_types: actor_user_types, target_user_types })) {
|
||||
throw new AppError(`You can't delete ${user.first_name || "this user"}.`);
|
||||
}
|
||||
|
||||
const containers =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.user_id = ?`,
|
||||
values: [user.id],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const container of containers) {
|
||||
if (container.docker_id || container.name) {
|
||||
await removeDockerContainer({ container: container.docker_id || container.name! });
|
||||
}
|
||||
}
|
||||
|
||||
const media_ids =
|
||||
(
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT id FROM media WHERE user_id = ?`,
|
||||
values: [user.id],
|
||||
})
|
||||
).payload?.map((media) => media.id) || [];
|
||||
|
||||
if (media_ids.length) {
|
||||
await deleteMedia({ ids: media_ids, user_id: user.id });
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({ sql: `DELETE FROM users WHERE id = ?`, values: [user.id] });
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't delete the user: ${res.error}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* These point at the user without a foreign key, so nothing cascades.
|
||||
*/
|
||||
for (const table of ["user_types", "user_passwords", "sso_login_codes"]) {
|
||||
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
|
||||
}
|
||||
|
||||
if (containers.length) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM containers WHERE id IN (${containers.map(() => "?").join(", ")})`,
|
||||
values: containers.map((container) => container.id!),
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -29,21 +29,22 @@ export default async function grabUsers(
|
||||
sql += `SELECT\n`;
|
||||
sql += ` u.*,\n`;
|
||||
sql += ` ut_agg.user_types,\n`;
|
||||
sql += ` m.id AS profile_media_id\n`;
|
||||
/**
|
||||
* Their profile photo only: other primary media (a bot's picture)
|
||||
* must not stand in for it.
|
||||
*/
|
||||
sql += ` (\n`;
|
||||
sql += ` SELECT m.id FROM media m\n`;
|
||||
sql += ` JOIN media_paradigms mp ON mp.media_id = m.id AND mp.media_paradigm = 'user-profile-image'\n`;
|
||||
sql += ` WHERE m.user_id = u.id AND m.is_primary = 1\n`;
|
||||
sql += ` ORDER BY m.id DESC LIMIT 1\n`;
|
||||
sql += ` ) AS profile_media_id\n`;
|
||||
sql += `FROM users u\n`;
|
||||
sql += `LEFT JOIN (\n`;
|
||||
sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`;
|
||||
sql += ` FROM user_types\n`;
|
||||
sql += ` GROUP BY user_id\n`;
|
||||
sql += `) ut_agg ON u.id = ut_agg.user_id\n`;
|
||||
sql += `LEFT JOIN media m ON (\n`;
|
||||
sql += ` u.id = m.user_id\n`;
|
||||
sql += ` AND m.is_primary = 1\n`;
|
||||
sql += `)\n`;
|
||||
sql += `LEFT JOIN media_paradigms mp ON (\n`;
|
||||
sql += ` m.id = mp.media_id\n`;
|
||||
sql += ` AND mp.media_paradigm = 'user-profile-image'\n`;
|
||||
sql += `)\n`;
|
||||
sql += `WHERE COALESCE(u.archived, 0) <> 1\n`;
|
||||
|
||||
const target_user_id = params?.user_id || query?.user_id;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
|
||||
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
|
||||
import type { SaveUserRequest } from "@/src/schemas/user-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import userTypeAssignment from "@/src/utils/rbac/user-type-assignment";
|
||||
import validatePassword from "@/src/utils/validate-password";
|
||||
import grabUsers from "./grab-users";
|
||||
|
||||
type Params = SaveUserRequest & {
|
||||
/**
|
||||
* The user types of whoever is saving: what they may grant or remove
|
||||
* is the assignment matrix's call (`userTypeAssignment`).
|
||||
*/
|
||||
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a user (a password is required: login is password only) or
|
||||
* updates one, then sets their user types to `user_types`: each one
|
||||
* granted or removed must pass the assignment matrix, and uneditable ones
|
||||
* (`super_admin`) stay as they are. The super admin's types are never
|
||||
* changed here, and they can't be revoked. A user needs at least one type to sign
|
||||
* in. Email and username stay unique. Returns the user's id and whether
|
||||
* they're new.
|
||||
*/
|
||||
export default async function saveUser({
|
||||
actor_user_types,
|
||||
user_id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
user_types,
|
||||
}: Params) {
|
||||
email = email.trim().toLowerCase();
|
||||
username = username.trim().toLowerCase();
|
||||
|
||||
if (!AppData["UsernameRegex"].test(username)) {
|
||||
throw new AppError(`Usernames must be 3-32 characters: lowercase letters, numbers, dots, dashes or underscores`);
|
||||
}
|
||||
|
||||
if (password || !user_id) {
|
||||
const password_error = validatePassword({ password });
|
||||
if (password_error) throw new AppError(password_error);
|
||||
}
|
||||
|
||||
const existing = user_id ? (await grabUsers({ user_id })).singleRes : null;
|
||||
|
||||
if (user_id && !existing?.id) {
|
||||
throw new AppError(`User not found.`);
|
||||
}
|
||||
|
||||
const current = parseUserTypes({ user_types: existing?.user_types });
|
||||
const is_super_admin = current.includes("super_admin");
|
||||
|
||||
if (is_super_admin && user_types.includes("revoked")) {
|
||||
throw new AppError(`The super admin can't be revoked.`);
|
||||
}
|
||||
|
||||
const kept = is_super_admin ? current : current.filter((type) => UneditableUserTypes.includes(type));
|
||||
const wanted = is_super_admin
|
||||
? []
|
||||
: [...new Set(user_types)].filter((type) => !UneditableUserTypes.includes(type));
|
||||
const added = wanted.filter((type) => !current.includes(type));
|
||||
const removed = current.filter((type) => !kept.includes(type) && !wanted.includes(type));
|
||||
|
||||
if (!kept.length && !wanted.length) {
|
||||
throw new AppError(`Give them at least one user type, or they can't sign in.`);
|
||||
}
|
||||
|
||||
for (const [types, action] of [[added, "assign"], [removed, "delete"]] as const) {
|
||||
for (const target_user_type of types) {
|
||||
if (!userTypeAssignment({ actor_user_types, target_user_type, target_user_types: current, action })) {
|
||||
const title = UserTypes.find((type) => type.value == target_user_type)?.title || target_user_type;
|
||||
throw new AppError(`You can't ${action == "assign" ? "grant" : "remove"} ${title}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const taken = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_USERS>({
|
||||
sql: `SELECT id, email, username FROM users WHERE (email = ? OR username = ?) AND id != ? LIMIT 1`,
|
||||
values: [email, username, user_id || 0],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (taken) {
|
||||
throw new AppError(taken.email == email ? `That email is already in use.` : `That username is taken.`);
|
||||
}
|
||||
|
||||
let target_id = Number(user_id || 0);
|
||||
|
||||
if (existing?.id) {
|
||||
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "users",
|
||||
targetId: existing.id,
|
||||
data: { first_name, last_name, email, username },
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't update the user: ${res.msg}`);
|
||||
} else {
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "users",
|
||||
data: [{ first_name, last_name, email, username }],
|
||||
});
|
||||
|
||||
target_id = Number(res.singleRes?.id || res.postInsertReturn?.insertId || 0);
|
||||
|
||||
if (!res.success || !target_id) throw new Error(`Couldn't create the user: ${res.msg}`);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
await setUserPassword({ user_id: target_id, password });
|
||||
}
|
||||
|
||||
if (removed.length) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM user_types WHERE user_id = ? AND user_type IN (${removed.map(() => "?").join(", ")})`,
|
||||
values: [target_id, ...removed],
|
||||
});
|
||||
}
|
||||
|
||||
if (added.length) {
|
||||
await BunSQLite.insert<BUN_SQLITE_COBBLE_USER_TYPES, (typeof BunSQLiteTables)[number]>({
|
||||
table: "user_types",
|
||||
data: added.map((user_type) => ({ user_id: target_id, user_type })),
|
||||
});
|
||||
}
|
||||
|
||||
return { user_id: target_id, created: !existing?.id };
|
||||
}
|
||||
Reference in New Issue
Block a user