Make Cobble a local install: setup flow, bots-first admin, settings

Local, password-only auth:
- No public pages; everything outside /admin and /auth redirects in
- First run (no users) sends every page to /auth/signup, which creates
  the super admin (user #1) once and then locks
- Login is email/username + password; removed the emailed-code flow,
  /auth/sso and email password reset
- Google login kept for existing accounts only, never creates them
- seed-super-admin now requires SEED_SUPER_ADMIN_PASSWORD

Bots-first admin:
- /admin and closed sections (people, logs, backups) redirect to
  /admin/bots; only bots and settings are live (AdminSections)
- Removed the old admin layout; every /admin page uses the bots rail
- Coordinator bot created at signup/seed and by /admin/bots
  (ensureCoordinatorBot), which redirects to its chat
- bots.system_prompt (markdown) with a default coordinator prompt
- seed-bots reuses the existing coordinator

Settings:
- Sub pages general (default), profile, change-password, listed in the
  rail with a back link; unknown settings paths redirect to general
- Redesigned settings UI: pane header, grouped rows, sticky save bar
- General: System/Light/Dark theme, saved per device

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 17:49:25 +01:00
co-authored by Claude Opus 5.5
parent 3286b80029
commit 694c83346f
159 changed files with 1891 additions and 2964 deletions
+57 -99
View File
@@ -1,13 +1,12 @@
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
import issueSSOLoginCode from "@/src/functions/backend/auth/issue-sso-login-code";
import createSignupUser from "@/src/functions/backend/auth/create-signup-user";
import createSuperAdminUser from "@/src/functions/backend/auth/create-super-admin-user";
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
import grabLoginMethod from "@/src/functions/backend/auth/grab-login-method";
import grabHasUsers from "@/src/functions/backend/auth/grab-has-users";
import loginUser from "@/src/functions/backend/auth/login-user";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import validatePassword from "@/src/utils/validate-password";
import { grabRequestIP, logError } from "@/src/functions/backend/logger";
import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
import { AppData } from "@/src/data/app-data";
import rateLimit from "@/src/utils/rate-limit";
import BunSQLite from "@moduletrace/bun-sqlite";
import { SignupRequestSchema } from "@/src/schemas/auth-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -17,11 +16,30 @@ import type {
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* First-run setup: creates the super admin and their coordinator bot, then
* logs them in. Refused once
* any account exists, so signup can only ever happen once per install.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
const ip_limit = rateLimit({
key: `signup:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["PasswordAttemptLimit"],
window_ms: AppData["RateLimitWindowMinutes"] * 60 * 1000,
});
if (!ip_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
if (await grabHasUsers()) {
throw new AppError(`This install has already been set up.`);
}
const { signup } = parseWithSchema({
schema: SignupRequestSchema,
data: body,
@@ -30,117 +48,57 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
const email = signup.email.trim().toLowerCase();
const username = signup.username.trim().toLowerCase();
const window_ms = AppData["RateLimitWindowMinutes"] * 60 * 1000;
const ip_limit = rateLimit({
key: `signup:ip:${grabRequestIP(req) || "unknown"}`,
limit: AppData["SSOCodeRequestLimit"] * 4,
window_ms,
});
const email_limit = rateLimit({
key: `signup:email:${email}`,
limit: AppData["SSOCodeRequestLimit"],
window_ms,
});
if (!ip_limit.allowed || !email_limit.allowed) {
throw new AppError(`Too many requests. Please try again later.`);
}
if (!AppData["UsernameRegex"].test(username)) {
throw new AppError(
`Usernames must be 3-32 characters: lowercase letters, numbers, dots, dashes or underscores`,
);
}
const login_method = grabLoginMethod();
const password_error =
login_method == "password"
? validatePassword({ password: signup.password })
: undefined;
const password_error = validatePassword({ password: signup.password });
if (password_error) {
throw new AppError(password_error);
}
const existing_email_user = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
email: {
value: email,
},
},
},
})
)?.singleRes;
/**
* Respond exactly as a fresh signup would, so this endpoint can't
* be used to discover which emails have accounts. In code mode the
* owner gets a login code. In password mode nothing is sent, since
* a code alone mustn't skip their password.
*/
if (existing_email_user?.id) {
if (login_method == "password") {
return {
success: true,
};
}
return issueSSOLoginCode({
user_id: existing_email_user.id,
email,
method: "code",
});
}
const existing_username_user = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
username: {
value: username,
},
},
},
})
)?.singleRes;
if (existing_username_user?.id) {
throw new AppError(
`That username is taken. Please choose another.`,
);
}
const new_user_id = await createSignupUser({
const new_user_id = await createSuperAdminUser({
first_name: signup.first_name.trim(),
last_name: signup.last_name?.trim(),
email,
username,
});
if (login_method == "password" && signup.password) {
await setUserPassword({
user_id: new_user_id,
password: signup.password,
});
}
return issueSSOLoginCode({
await setUserPassword({
user_id: new_user_id,
email,
method: login_method,
password: signup.password,
});
/**
* The account exists now, so a failure here mustn't fail signup
* (which is one-shot). `/admin/bots` creates it on the next visit.
*/
await ensureCoordinatorBot({ user_id: new_user_id }).catch((error) =>
logError({
title: "Coordinator bot not created at signup",
error,
user_id: new_user_id,
ip_address: grabRequestIP(req),
}).catch(() => {}),
);
await logEvent({
title: "Super admin created",
category: "auth",
user_id: new_user_id,
plain_text: `User #${new_user_id} created the super admin account.`,
metadata: {
user_id: new_user_id,
},
ip_address: grabRequestIP(req),
});
return await loginUser({
user_id: new_user_id,
req,
});
} catch (error: unknown) {
await logError({