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
@@ -14,10 +14,10 @@ type Params = {
};
/**
* Creates a self-signup account with the default `read_only` user type
* and returns its id.
* Creates the first account on a fresh install with the `super_admin`
* user type and returns its id.
*/
export default async function createSignupUser({
export default async function createSuperAdminUser({
first_name,
last_name,
email,
@@ -57,7 +57,7 @@ export default async function createSignupUser({
data: [
{
user_id: new_user_id,
user_type: "read_only",
user_type: "super_admin",
},
],
});
@@ -1,25 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: string | number;
};
export default async function deleteUserSSOCodes({ user_id }: Params) {
return await BunSQLite.delete<
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
query: {
query: {
user_id: {
value: user_id,
},
},
},
});
}
@@ -1,47 +0,0 @@
import { randomInt } from "node:crypto";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
email: string;
};
/**
* Derives a free username from an email's local part, adding a numeric
* suffix when the plain version is taken.
*/
export default async function generateUniqueUsername({ email }: Params) {
const base = (email.split("@")[0] || "user")
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "")
.slice(0, 24)
.padEnd(3, "0");
for (let attempt = 0; attempt < 6; attempt++) {
const candidate =
attempt == 0 ? base : `${base}${randomInt(1000, 100000)}`;
const existing = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
username: {
value: candidate,
},
},
},
})
)?.singleRes;
if (!existing?.id) {
return candidate;
}
}
throw new AppError(`Couldn't pick a username. Please sign up with the form.`);
}
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
/**
* Whether any account exists yet. A fresh install has none, and is
* sent to `/auth/signup` to create the super admin.
*/
export default async function grabHasUsers() {
const res = await BunSQLite.select<
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
limit: 1,
},
});
return Boolean(res?.payload?.[0]?.id);
}
@@ -1,8 +0,0 @@
import parseLoginMethod from "@/src/utils/parse-login-method";
/**
* Server-side source of truth for the site-wide login method.
*/
export default function grabLoginMethod() {
return parseLoginMethod({ value: process.env.AUTH_LOGIN_METHOD });
}
@@ -1,142 +0,0 @@
import { randomInt } from "node:crypto";
import { AppData } from "@/src/data/app-data";
import { SiteData } from "@/src/data/site-data";
import sendEmail from "../email/send-email";
import resolveEmailRecipient from "../email/resolve-email-recipient";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { SSOAuth, SSOMethod } from "@/src/types";
import { setCookies } from "@/src/utils/cookies";
import { encrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import type {
APIResponseObject,
BunextAPIRouteHandlerObjectReturn,
} from "@moduletrace/bunext/types";
import type {
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: number;
email: string;
method: SSOMethod;
/**
* Carried over on resends so the pending login can't be extended
* indefinitely. Defaults to now.
*/
first_step_at?: number;
};
export default async function issueSSOLoginCode({
user_id,
email,
method,
first_step_at,
}: Params): Promise<BunextAPIRouteHandlerObjectReturn<APIResponseObject>> {
const sso_code = randomInt(0, 1000000).toString().padStart(6, "0");
const record_sso = await BunSQLite.insert<
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
data: [
{
code: sso_code,
user_id,
},
],
table: "sso_login_codes",
update_on_duplicate: true,
});
if (!record_sso.success) {
return {
success: false,
msg: `Couldn't issue a login code. Please try again.`,
};
}
if (process.env.NODE_ENV != "production") {
console.log(
`SSO login code for ${email} (user #${user_id}): ${sso_code}\n` +
`Code expires in ${AppData["SSOCodeExpiryMinutes"]} minutes.`,
);
}
const is_reset = method == "reset";
const email_component = (
<div>
<div>
{is_reset
? "Use this code to reset your password"
: "Use this Code to Complete your login"}
</div>
<h1>{sso_code}</h1>
<div>
Code expires in {AppData["SSOCodeExpiryMinutes"]} minutes.
</div>
</div>
);
const recipient = resolveEmailRecipient({ to: email });
if (recipient) {
await sendEmail({
content: email_component,
to: recipient,
subject: is_reset
? `Use this code to reset your password.`
: `Use one-time-code to complete your login.`,
text: is_reset
? `Reset your ${SiteData["SiteName"]} password`
: `Complete your login to the ${SiteData["SiteName"]} portal`,
title: `${SiteData["SiteName"]} SSO`,
options: {
priority: "high",
},
})
.then((mail_res) => {
if (!mail_res.success) {
console.log(
`SSO email to user #${user_id} failed:\n${mail_res.msg}`,
);
}
})
.catch((error) => {
console.log(
`SSO email to user #${user_id} errored:\n${error.message}`,
);
});
}
const sso_auth: SSOAuth = {
user_id,
sso_code,
email,
method,
first_step_at: first_step_at || Date.now(),
};
const sso_auth_string = EJSON.stringify(sso_auth);
if (!sso_auth_string) {
throw new Error(`Couldn't Stringify SSO Auth`);
}
const encrypted_sso_auth_string = await encrypt(sso_auth_string);
return {
success: true,
bunext_api_route_res_transform_fn(res: Response) {
const new_res = res.clone();
setCookies(new_res, [
{
name: AppData["SSOAuthCookieName"],
value: encrypted_sso_auth_string,
},
]);
return new_res;
},
};
}
-3
View File
@@ -13,7 +13,6 @@ import type { User } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import grabMediaURL from "../media/grab-media-url";
import logEvent, { grabRequestIP } from "../logger";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
@@ -121,8 +120,6 @@ export default async function loginUser({
},
]);
await deleteUserSSOCodes({ user_id: new_logged_in_user.id });
if (!reauth) {
await logEvent({
title: "User logged in",
@@ -4,7 +4,6 @@ import type {
BunSQLiteTables,
} from "@/db/types/db";
import hashPassword from "./hash-password";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
@@ -13,9 +12,7 @@ type Params = {
};
/**
* Creates or replaces the user's password. Outstanding login codes are
* revoked, since a code issued before the change would skip the new
* password.
* Creates or replaces the user's password.
*/
export default async function setUserPassword({ user_id, password }: Params) {
const password_hash = await hashPassword({ password });
@@ -37,6 +34,4 @@ export default async function setUserPassword({ user_id, password }: Params) {
if (!res.success) {
throw new AppError(`Couldn't save your password. Please try again.`);
}
await deleteUserSSOCodes({ user_id });
}
@@ -1,105 +0,0 @@
import type {
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { SSOAuth, SSOMethod } from "@/src/types";
import { getCookie } from "@/src/utils/cookies";
import { decrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import { safeCompare } from "@/src/utils/safe-compare";
import rateLimit, { resetRateLimit } from "@/src/utils/rate-limit";
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
req: Request;
sso_code?: string | number;
/**
* The code's issuing method must match, e.g. a reset code can't
* complete a login.
*/
method: SSOMethod;
};
/**
* Checks an emailed code against the pending login in the SSO cookie.
* Returns the user id on success, throws a user-facing error otherwise.
*/
export default async function verifySSOCode({
req,
sso_code,
method,
}: Params) {
const sso_cookie = getCookie(req, AppData["SSOAuthCookieName"]);
if (!sso_cookie) {
throw new AppError(`No login in progress. Please start again.`);
}
if (!sso_code) {
throw new AppError(`Please enter your code.`);
}
const sso_auth = EJSON.parse(await decrypt(sso_cookie)) as SSOAuth;
if (!sso_auth?.user_id) {
throw new AppError(`Invalid login session. Please start again.`);
}
const user_id = sso_auth.user_id;
if (sso_auth.method != method) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`This code can't be used here. Please start again.`);
}
const attempts_key = `sso-login:${user_id}`;
const attempts = rateLimit({
key: attempts_key,
limit: AppData["SSOMaxCodeAttempts"],
window_ms: AppData["SSOCodeExpiryMinutes"] * 60 * 1000,
});
if (!attempts.allowed) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`Too many attempts. Please request a new code.`);
}
const target_sso = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).singleRes;
if (
!target_sso?.code ||
!safeCompare(String(target_sso.code), String(sso_code))
) {
throw new AppError(`Invalid Code!`);
}
const time_elapsed = Date.now() - Number(target_sso.updated_at);
const expiry_time = AppData["SSOCodeExpiryMinutes"] * 60 * 1000;
if (time_elapsed > expiry_time) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`Code Expired. Please start again.`);
}
resetRateLimit({ key: attempts_key });
return user_id;
}
@@ -0,0 +1,71 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import { CoordinatorBotName } from "@/src/dict/bot-dict";
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
type Params = {
user_id: number | string;
};
/**
* Returns the id of the user's coordinator, creating it first (with the
* default `CoordinatorSystemPrompt`) if the workspace has none. A
* coordinator without a prompt gets the default; an edited prompt is never
* overwritten. Safe to call on every visit.
*/
export default async function ensureCoordinatorBot({ user_id }: Params) {
const existing = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_BOTS,
(typeof BunSQLiteTables)[number]
>({
table: "bots",
query: {
query: {
user_id: { value: user_id },
kind: { value: "coordinator" },
},
order: { field: "id", strategy: "ASC" },
limit: 1,
},
})
).payload?.[0];
if (existing?.id) {
if (!existing.system_prompt?.trim()) {
await BunSQLite.sql({
sql: `UPDATE bots SET system_prompt = ? WHERE id = ?`,
values: [CoordinatorSystemPrompt, existing.id],
});
}
return existing.id;
}
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOTS,
(typeof BunSQLiteTables)[number]
>({
table: "bots",
data: [
{
user_id: Number(user_id),
name: CoordinatorBotName,
kind: "coordinator",
face: "idle",
status_line: "",
system_prompt: CoordinatorSystemPrompt,
unread: 0,
last_activity_at: Date.now(),
},
],
});
const bot_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !bot_id) {
throw new Error(`Couldn't create the coordinator: ${res.msg}`);
}
return bot_id;
}