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
@@ -6,6 +6,7 @@ import {
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
export type RailUserMenuItem = {
title: string;
@@ -26,9 +27,9 @@ export type RailUserMenuItem = {
* destinations are only reachable from here.
*/
export const RailUserMenuItems: RailUserMenuItem[] = [
{ title: "Activity", url: "/admin/logs", Icon: Activity },
{ title: "Activity", Icon: Activity },
{ title: "Routines", Icon: Clock },
{ title: "Settings", url: "/admin/settings", Icon: SlidersHorizontal },
{ title: "Settings", url: SettingsDefaultPath, Icon: SlidersHorizontal },
{ title: "Help", Icon: CircleHelp },
{ title: "Sign out", url: "/auth/logout", Icon: LogOut, destructive: true },
];
@@ -0,0 +1,25 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { ArrowLeft } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
/**
* Top of the rail on settings pages, returning to the conversations. Same
* 48px row as a bot.
*/
export default function RailBackLink() {
return (
<Link
href={AdminHomePath}
title="Back to bots"
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<ArrowLeft size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
Back to bots
</Span>
</Link>
);
}
@@ -0,0 +1,35 @@
import type { BotWithLastMessage } from "@/src/types";
import CoordinatorRow from "./coordinator-row";
import BotRow from "./bot-row";
type Props = {
bots: BotWithLastMessage[];
selected_id?: number;
};
/**
* The pinned coordinator, then the crew.
*/
export default function RailCrewList({ bots, selected_id }: Props) {
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
return (
<>
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
selected={coordinator.id === selected_id}
/>
) : null}
{workers.map((bot) => (
<BotRow
key={bot.id}
bot={bot}
selected={bot.id === selected_id}
/>
))}
</>
);
}
+9 -2
View File
@@ -4,7 +4,14 @@ import Wordmark from "@/src/components/general/wordmark";
import LogoIcon from "@/src/components/general/logo-icon";
import NewBotButton from "./new-bot-button";
export default function RailHeader() {
type Props = {
/**
* Off on settings pages, where adding a bot is out of place.
*/
show_new_bot?: boolean;
};
export default function RailHeader({ show_new_bot = true }: Props) {
return (
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
<Link
@@ -14,7 +21,7 @@ export default function RailHeader() {
<Wordmark height={15} className="hidden lg:block" />
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
</Link>
<NewBotButton />
{show_new_bot ? <NewBotButton /> : null}
</Row>
);
}
@@ -0,0 +1,35 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import type { SettingsPage } from "@/src/dict/settings-pages-dict";
type Props = SettingsPage & {
selected?: boolean;
};
/**
* A settings sub page in the rail. Same 48px row and selected state as a
* bot row; icon only when the rail collapses.
*/
export default function RailSettingsLink({ title, url, Icon, selected }: Props) {
return (
<Link
href={url}
title={title}
aria-current={selected ? "page" : undefined}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<Icon size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
{title}
</Span>
</Link>
);
}
@@ -0,0 +1,32 @@
import Divider from "@moduletrace/twui/layout/Divider";
import { SettingsPages } from "@/src/dict/settings-pages-dict";
import RailBackLink from "./rail-back-link";
import RailSettingsLink from "./rail-settings-link";
type Props = {
pathname?: string;
};
/**
* Settings mode of the rail: back to the bots, then the settings sub pages.
*/
export default function RailSettingsList({ pathname }: Props) {
const current = pathname?.replace(/\/$/, "");
return (
<>
<RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{SettingsPages.map((page) => (
<RailSettingsLink
key={page.url}
{...page}
selected={
current == page.url ||
Boolean(current?.startsWith(`${page.url}/`))
}
/>
))}
</>
);
}
+16 -23
View File
@@ -2,43 +2,36 @@ import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import RailHeader from "../(partials)/rail-header";
import CoordinatorRow from "../(partials)/coordinator-row";
import BotRow from "../(partials)/bot-row";
import RailCrewList from "../(partials)/rail-crew-list";
import RailSettingsList from "../(partials)/rail-settings-list";
import RailUserRow from "../(partials)/rail-user-row";
/**
* Left rail: wordmark, the pinned coordinator, then the crew. Collapses to a
* 64px icon rail below 1024px.
* Left rail: wordmark, then the crew (pinned coordinator first). On
* `/admin/settings/*` the crew is swapped for a back link and the settings
* sub pages. Collapses to a 64px icon rail below 1024px.
*/
export default function BotsRail() {
const { pageProps } = useContext(AppContext);
const bots = pageProps?.bots || [];
const selected_id = pageProps?.bot?.id;
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
const pathname = pageProps?.url?.pathname;
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
return (
<Stack
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
aria-label="Bots"
aria-label={is_settings_page ? "Settings" : "Bots"}
>
<RailHeader />
<RailHeader show_new_bot={!is_settings_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1">
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
selected={coordinator.id === selected_id}
{is_settings_page ? (
<RailSettingsList pathname={pathname} />
) : (
<RailCrewList
bots={pageProps?.bots || []}
selected_id={pageProps?.bot?.id || undefined}
/>
) : null}
{workers.map((bot) => (
<BotRow
key={bot.id}
bot={bot}
selected={bot.id === selected_id}
/>
))}
)}
</Stack>
<RailUserRow />
</Stack>
+2 -2
View File
@@ -3,8 +3,8 @@ import Row from "@moduletrace/twui/layout/Row";
import BotsRail from "./(sections)/bots-rail";
/**
* Full-bleed chat shell for `/admin/bots/*`: the rail of bot conversations,
* then the page (conversation + working-on panel). No cards, no shadows;
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations,
* then the page (conversation + working-on panel, or settings). No cards, no shadows;
* regions are separated by 1px hairlines.
*/
export default function BotsLayout({