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
+5 -14
View File
@@ -1,4 +1,5 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type useFormInit from "@/src/hooks/use-form-init";
import { AppContext } from "@/src/pages/__root";
import type { ApiReqParams, GoogleWindow } from "@/src/types";
@@ -8,20 +9,15 @@ import { useContext, useEffect, useRef, useState } from "react";
const GoogleScriptSelector =
'script[src="https://accounts.google.com/gsi/client"]';
export type GoogleAuthMode = "login" | "signup";
type Params = Pick<
ReturnType<typeof useFormInit>,
"loading" | "setLoading" | "setStatus"
> & {
mode: GoogleAuthMode;
};
>;
export default function useGoogleAuth({
loading,
setStatus,
setLoading,
mode,
}: Params) {
const { pageProps } = useContext(AppContext);
@@ -42,21 +38,16 @@ export default function useGoogleAuth({
method: "POST",
body: {
google_token,
google_signup: mode == "signup",
},
},
);
if (res.success) {
window.location.pathname = `/admin`;
window.location.pathname = AdminHomePath;
} else {
setStatus({
error: true,
msg:
res.msg ||
(mode == "signup"
? "Couldn't sign up with Google."
: "Couldn't log in with Google."),
msg: res.msg || "Couldn't log in with Google.",
});
setLoading(false);
}
@@ -175,7 +166,7 @@ export default function useGoogleAuth({
googleId.renderButton(googleButtonRef.current, {
theme: "outline",
size: "large",
text: mode == "signup" ? "signup_with" : "signin_with",
text: "signin_with",
shape: "rectangular",
width: Math.min(buttonWidth, 400),
});
-13
View File
@@ -1,13 +0,0 @@
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import type { LoginMethod } from "@/src/types";
/**
* Site-wide login method from `AUTH_LOGIN_METHOD`, passed down by
* `__root.server.ts`.
*/
export default function useLoginMethod(): LoginMethod {
const { pageProps } = useContext(AppContext);
return pageProps?.envs?.AUTH_LOGIN_METHOD == "password" ? "password" : "code";
}
-13
View File
@@ -1,13 +0,0 @@
import { useEffect, useState } from "react";
import readSSOPendingLogin from "@/src/functions/frontend/read-sso-pending-login";
import type { SSOPendingLogin } from "@/src/types";
export default function useSSOPendingLogin() {
const [pending, setPending] = useState<SSOPendingLogin>();
useEffect(() => {
setPending(readSSOPendingLogin());
}, []);
return pending;
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { AppData } from "@/src/data/app-data";
import type { ThemePreference } from "@/src/dict/theme-preferences-dict";
import resolveTheme from "@/src/utils/resolve-theme";
const DarkQuery = "(prefers-color-scheme: dark)";
function readPreference(): ThemePreference {
try {
const stored = localStorage.getItem(AppData["ThemeStorageKey"]);
return stored == "dark" || stored == "light" ? stored : "system";
} catch {
return "system";
}
}
function applyTheme({ preference }: { preference: ThemePreference }) {
const theme = resolveTheme({
preference,
system_is_dark: window.matchMedia(DarkQuery).matches,
});
document.documentElement.classList.remove("dark", "light");
document.documentElement.classList.add(theme);
}
/**
* This device's color scheme choice, stored where `ThemeInitScript` reads
* it. `system` stores nothing and tracks the OS live.
*/
export default function useThemePreference() {
const [preference, setPreferenceState] = useState<ThemePreference>();
useEffect(() => {
setPreferenceState(readPreference());
}, []);
useEffect(() => {
if (preference != "system") return;
const media = window.matchMedia(DarkQuery);
const onChange = () => applyTheme({ preference: "system" });
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [preference]);
const setPreference = (next: ThemePreference) => {
try {
if (next == "system") {
localStorage.removeItem(AppData["ThemeStorageKey"]);
} else {
localStorage.setItem(AppData["ThemeStorageKey"], next);
}
} catch {}
applyTheme({ preference: next });
setPreferenceState(next);
};
return { preference, setPreference };
}