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:
+4
-4
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user