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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user