diff --git a/AGENTS.md b/AGENTS.md index 1a30648..f834816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl ### Auth - **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request. - - No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People and backups exist in code but are closed until added back to `AdminSections` + - No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People exists in code but is closed until added back to `AdminSections` - No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away - Redirects unauthenticated `/admin/*` → `/auth/login` - Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general` @@ -158,7 +158,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - `/admin/logs` (live) - Activity, the audit log: super admins only (others are redirected; the popover's "Activity" item shows to super admins only). Rendered in `SettingsShell` with `wide` - `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it) - Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings -- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import ` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` +- `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import ` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` - `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). diff --git a/src/dict/admin-sections-dict.ts b/src/dict/admin-sections-dict.ts index abc521f..130d6fc 100644 --- a/src/dict/admin-sections-dict.ts +++ b/src/dict/admin-sections-dict.ts @@ -1,7 +1,7 @@ /** * The admin sections that are live. Any other `/admin/*` path redirects to - * `AdminHomePath`. People and backups exist in code but stay closed until - * they are added back here. Logs (Activity) is super admins only. + * `AdminHomePath`. People exists in code but stays closed until it is added + * back here. Logs (Activity) is super admins only. */ export const AdminSections = [ "/admin/bots", diff --git a/src/dict/settings-pages-dict.ts b/src/dict/settings-pages-dict.ts index 7a29fe8..88b3ed9 100644 --- a/src/dict/settings-pages-dict.ts +++ b/src/dict/settings-pages-dict.ts @@ -2,6 +2,7 @@ import { Bell, Bot, CpuAi, + DatabaseBackup, KeyRound, Layers, Monitor, @@ -32,6 +33,8 @@ export type SettingsPage = { }; export const SettingsComputersPath = "/admin/settings/computers"; +export const SettingsBackupsPath = "/admin/settings/backups"; +export const SettingsBackupsRestorePath = `${SettingsBackupsPath}/restore`; /** * The `/admin/settings/*` sub pages, listed in the rail on every settings @@ -77,6 +80,12 @@ export const SettingsPages: SettingsPage[] = [ Icon: Variable, super_admin_only: true, }, + { + title: "Backups", + url: SettingsBackupsPath, + Icon: DatabaseBackup, + super_admin_only: true, + }, ]; export const SettingsDefaultPath = "/admin/settings/general"; diff --git a/src/pages/admin/backups/(functions)/backups-page-meta.ts b/src/pages/admin/backups/(functions)/backups-page-meta.ts deleted file mode 100644 index 3d3c961..0000000 --- a/src/pages/admin/backups/(functions)/backups-page-meta.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types"; -import { SiteData } from "@/src/data/site-data"; - -type Params = { - label: string; - description: string; -}; - -export default function backupsPageMeta({ - label, - description, -}: Params): BunextPageModuleMetaFn { - return async () => { - return { - title: `${label} | ${SiteData["SiteName"]}`, - description, - }; - }; -} diff --git a/src/pages/admin/backups/(functions)/grab-backups-server-props.ts b/src/pages/admin/backups/(functions)/grab-backups-server-props.ts deleted file mode 100644 index 320267b..0000000 --- a/src/pages/admin/backups/(functions)/grab-backups-server-props.ts +++ /dev/null @@ -1,27 +0,0 @@ -import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; -import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups"; -import type { PagePropsType } from "@/src/types"; -import type { BunextPageServerFn } from "@moduletrace/bunext/types"; - -type Params = Parameters>[0]; - -/** - * Shared server props for the `/admin/backups` sub-pages. Super admin only. - */ -export default async function grabBackupsServerProps({ req }: Params) { - const user = await grabSuperAdminUser({ req }); - - if (!user?.id) { - return { - redirect: { - destination: "/admin", - }, - }; - } - - return { - props: { - backups: grabDbBackups(), - }, - }; -} diff --git a/src/pages/admin/backups/(sections)/backups-list-section.tsx b/src/pages/admin/backups/(sections)/backups-list-section.tsx deleted file mode 100644 index eb2b0b3..0000000 --- a/src/pages/admin/backups/(sections)/backups-list-section.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import EmptyContent from "@moduletrace/twui/elements/EmptyContent"; -import Section from "@moduletrace/twui/layout/Section"; -import Span from "@moduletrace/twui/layout/Span"; -import Stack from "@moduletrace/twui/layout/Stack"; -import { AppContext } from "@/src/pages/__root"; -import { useContext } from "react"; -import BackupsTable, { type BackupsTableMode } from "../(partials)/backups-table"; - -type Props = { - mode: BackupsTableMode; -}; - -export default function BackupsListSection({ mode }: Props) { - const { pageProps } = useContext(AppContext); - - const backups = pageProps?.backups || []; - - return ( -
- - - {`${backups.length} backup${backups.length == 1 ? "" : "s"}`} - - {backups[0] ? ( - - ) : ( - - )} - -
- ); -} diff --git a/src/pages/admin/backups/(sections)/hero.tsx b/src/pages/admin/backups/(sections)/hero.tsx deleted file mode 100644 index c663211..0000000 --- a/src/pages/admin/backups/(sections)/hero.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import AdminHero from "@/src/components/general/admin-hero"; -import CreateBackupButton from "../(partials)/create-backup-button"; - -export default function Hero() { - return ( - } - /> - ); -} diff --git a/src/pages/admin/backups/index.tsx b/src/pages/admin/backups/index.tsx deleted file mode 100644 index cc6b87c..0000000 --- a/src/pages/admin/backups/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Hero from "./(sections)/hero"; -import BackupsListSection from "./(sections)/backups-list-section"; -import backupsPageMeta from "./(functions)/backups-page-meta"; - -export default function AdminBackupsPage() { - return ( - <> - - - - ); -} - -export const meta = backupsPageMeta({ - label: "Backups", - description: "Create and download database backups", -}); diff --git a/src/pages/admin/backups/restore/(sections)/hero.tsx b/src/pages/admin/backups/restore/(sections)/hero.tsx deleted file mode 100644 index 9ad9954..0000000 --- a/src/pages/admin/backups/restore/(sections)/hero.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import AdminHero from "@/src/components/general/admin-hero"; - -export default function Hero() { - return ( - - ); -} diff --git a/src/pages/admin/backups/restore/(sections)/upload-restore-section.tsx b/src/pages/admin/backups/restore/(sections)/upload-restore-section.tsx deleted file mode 100644 index a681441..0000000 --- a/src/pages/admin/backups/restore/(sections)/upload-restore-section.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Paper from "@moduletrace/twui/elements/Paper"; -import H3 from "@moduletrace/twui/layout/H3"; -import Section from "@moduletrace/twui/layout/Section"; -import Span from "@moduletrace/twui/layout/Span"; -import Stack from "@moduletrace/twui/layout/Stack"; -import UploadRestoreForm from "../(partials)/upload-restore-form"; - -export default function UploadRestoreSection() { - return ( -
- - - -

Restore from a file

- - Upload a SQLite database file. It is saved to the - backups list, then restored. - -
- -
-
-
- ); -} diff --git a/src/pages/admin/backups/restore/index.tsx b/src/pages/admin/backups/restore/index.tsx deleted file mode 100644 index aaf77be..0000000 --- a/src/pages/admin/backups/restore/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Hero from "./(sections)/hero"; -import UploadRestoreSection from "./(sections)/upload-restore-section"; -import BackupsListSection from "../(sections)/backups-list-section"; -import backupsPageMeta from "../(functions)/backups-page-meta"; - -export default function AdminBackupsRestorePage() { - return ( - <> - - - - - ); -} - -export const meta = backupsPageMeta({ - label: "Restore Backup", - description: "Restore the database from a backup", -}); diff --git a/src/pages/admin/backups/(data)/backups-table-columns.ts b/src/pages/admin/settings/backups/(data)/backups-table-columns.ts similarity index 100% rename from src/pages/admin/backups/(data)/backups-table-columns.ts rename to src/pages/admin/settings/backups/(data)/backups-table-columns.ts diff --git a/src/pages/admin/backups/(functions)/create-backup.ts b/src/pages/admin/settings/backups/(functions)/create-backup.ts similarity index 100% rename from src/pages/admin/backups/(functions)/create-backup.ts rename to src/pages/admin/settings/backups/(functions)/create-backup.ts diff --git a/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts b/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts new file mode 100644 index 0000000..077a74f --- /dev/null +++ b/src/pages/admin/settings/backups/(functions)/grab-backups-server-props.ts @@ -0,0 +1,35 @@ +import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; +import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups"; +import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict"; +import type { PagePropsType } from "@/src/types"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; +import grabSettingsServerProps from "../../(functions)/grab-settings-server-props"; + +type Params = Parameters>[0]; + +/** + * Shared server props for the `/admin/settings/backups` sub-pages: the + * saved snapshots. Super admins only; everyone else lands on the general + * settings. + */ +export default async function grabBackupsServerProps(params: Params) { + const settings = await grabSettingsServerProps(params); + + if (!settings.props) return settings; + + if (!(await grabSuperAdminUser({ req: params.req }))) { + return { + redirect: { + destination: SettingsDefaultPath, + permanent: false, + }, + }; + } + + return { + props: { + ...settings.props, + backups: grabDbBackups(), + }, + }; +} diff --git a/src/pages/admin/backups/(functions)/restore-backup.ts b/src/pages/admin/settings/backups/(functions)/restore-backup.ts similarity index 100% rename from src/pages/admin/backups/(functions)/restore-backup.ts rename to src/pages/admin/settings/backups/(functions)/restore-backup.ts diff --git a/src/pages/admin/backups/(partials)/backup-table-row.tsx b/src/pages/admin/settings/backups/(partials)/backup-table-row.tsx similarity index 100% rename from src/pages/admin/backups/(partials)/backup-table-row.tsx rename to src/pages/admin/settings/backups/(partials)/backup-table-row.tsx diff --git a/src/pages/admin/backups/(partials)/backups-table-head-cell.tsx b/src/pages/admin/settings/backups/(partials)/backups-table-head-cell.tsx similarity index 100% rename from src/pages/admin/backups/(partials)/backups-table-head-cell.tsx rename to src/pages/admin/settings/backups/(partials)/backups-table-head-cell.tsx diff --git a/src/pages/admin/backups/(partials)/backups-table.tsx b/src/pages/admin/settings/backups/(partials)/backups-table.tsx similarity index 100% rename from src/pages/admin/backups/(partials)/backups-table.tsx rename to src/pages/admin/settings/backups/(partials)/backups-table.tsx diff --git a/src/pages/admin/backups/(partials)/create-backup-button.tsx b/src/pages/admin/settings/backups/(partials)/create-backup-button.tsx similarity index 100% rename from src/pages/admin/backups/(partials)/create-backup-button.tsx rename to src/pages/admin/settings/backups/(partials)/create-backup-button.tsx diff --git a/src/pages/admin/backups/(partials)/download-backup-button.tsx b/src/pages/admin/settings/backups/(partials)/download-backup-button.tsx similarity index 100% rename from src/pages/admin/backups/(partials)/download-backup-button.tsx rename to src/pages/admin/settings/backups/(partials)/download-backup-button.tsx diff --git a/src/pages/admin/backups/(partials)/restore-backup-button.tsx b/src/pages/admin/settings/backups/(partials)/restore-backup-button.tsx similarity index 100% rename from src/pages/admin/backups/(partials)/restore-backup-button.tsx rename to src/pages/admin/settings/backups/(partials)/restore-backup-button.tsx diff --git a/src/pages/admin/settings/backups/(sections)/backups-list-section.tsx b/src/pages/admin/settings/backups/(sections)/backups-list-section.tsx new file mode 100644 index 0000000..a29db5a --- /dev/null +++ b/src/pages/admin/settings/backups/(sections)/backups-list-section.tsx @@ -0,0 +1,46 @@ +import { useContext } from "react"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import SettingsGroup from "../../(partials)/settings-group"; +import { AppContext } from "@/src/pages/__root"; +import CreateBackupButton from "../(partials)/create-backup-button"; +import BackupsTable, { type BackupsTableMode } from "../(partials)/backups-table"; + +type Props = { + mode: BackupsTableMode; +}; + +/** + * The database snapshots, newest first. On the restore page each row + * restores; otherwise each one downloads, and a new backup can be made. + */ +export default function BackupsListSection({ mode }: Props) { + const { pageProps } = useContext(AppContext); + const backups = pageProps?.backups || []; + const is_restore = mode == "restore"; + + return ( + + + + {`${backups.length} backup${backups.length == 1 ? "" : "s"}`} + + {is_restore ? null : } + + {backups[0] ? ( + + ) : ( + + No backups yet. + + )} + + ); +} diff --git a/src/pages/admin/settings/backups/(sections)/restore-backups-section.tsx b/src/pages/admin/settings/backups/(sections)/restore-backups-section.tsx new file mode 100644 index 0000000..5cab33e --- /dev/null +++ b/src/pages/admin/settings/backups/(sections)/restore-backups-section.tsx @@ -0,0 +1,28 @@ +import Button from "@moduletrace/twui/layout/Button"; +import { ArchiveRestore } from "lucide-react"; +import SettingsGroup from "../../(partials)/settings-group"; +import { SettingsBackupsRestorePath } from "@/src/dict/settings-pages-dict"; + +/** + * The way into the restore page from the backups list. + */ +export default function RestoreBackupsSection() { + return ( + + + + ); +} diff --git a/src/pages/admin/backups/index.server.ts b/src/pages/admin/settings/backups/index.server.ts similarity index 100% rename from src/pages/admin/backups/index.server.ts rename to src/pages/admin/settings/backups/index.server.ts diff --git a/src/pages/admin/settings/backups/index.tsx b/src/pages/admin/settings/backups/index.tsx new file mode 100644 index 0000000..0786b23 --- /dev/null +++ b/src/pages/admin/settings/backups/index.tsx @@ -0,0 +1,19 @@ +import BackupsListSection from "./(sections)/backups-list-section"; +import RestoreBackupsSection from "./(sections)/restore-backups-section"; +import SettingsShell from "../(partials)/settings-shell"; +import settingsPageMeta from "../(functions)/settings-page-meta"; + +export default function AdminSettingsBackupsPage() { + return ( + + + + + ); +} + +export const meta = settingsPageMeta({ label: "Backups" }); diff --git a/src/pages/admin/backups/restore/(functions)/upload-restore-backup.ts b/src/pages/admin/settings/backups/restore/(functions)/upload-restore-backup.ts similarity index 100% rename from src/pages/admin/backups/restore/(functions)/upload-restore-backup.ts rename to src/pages/admin/settings/backups/restore/(functions)/upload-restore-backup.ts diff --git a/src/pages/admin/backups/restore/(partials)/upload-restore-form.tsx b/src/pages/admin/settings/backups/restore/(partials)/upload-restore-form.tsx similarity index 100% rename from src/pages/admin/backups/restore/(partials)/upload-restore-form.tsx rename to src/pages/admin/settings/backups/restore/(partials)/upload-restore-form.tsx diff --git a/src/pages/admin/settings/backups/restore/(sections)/upload-restore-section.tsx b/src/pages/admin/settings/backups/restore/(sections)/upload-restore-section.tsx new file mode 100644 index 0000000..6244fec --- /dev/null +++ b/src/pages/admin/settings/backups/restore/(sections)/upload-restore-section.tsx @@ -0,0 +1,16 @@ +import SettingsGroup from "../../../(partials)/settings-group"; +import UploadRestoreForm from "../(partials)/upload-restore-form"; + +/** + * Restoring from a file on this machine, for a backup kept elsewhere. + */ +export default function UploadRestoreSection() { + return ( + + + + ); +} diff --git a/src/pages/admin/backups/restore/index.server.ts b/src/pages/admin/settings/backups/restore/index.server.ts similarity index 100% rename from src/pages/admin/backups/restore/index.server.ts rename to src/pages/admin/settings/backups/restore/index.server.ts diff --git a/src/pages/admin/settings/backups/restore/index.tsx b/src/pages/admin/settings/backups/restore/index.tsx new file mode 100644 index 0000000..c96a2b6 --- /dev/null +++ b/src/pages/admin/settings/backups/restore/index.tsx @@ -0,0 +1,26 @@ +import UploadRestoreSection from "./(sections)/upload-restore-section"; +import BackupsListSection from "../(sections)/backups-list-section"; +import SettingsShell from "../../(partials)/settings-shell"; +import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types"; +import { SiteData } from "@/src/data/site-data"; + +export default function AdminSettingsRestoreBackupPage() { + return ( + + + + + ); +} + +export const meta: BunextPageModuleMetaFn = async () => { + return { + title: `Restore backup | ${SiteData["SiteName"]}`, + description: `Restore a database backup on ${SiteData["SiteName"]}.`, + }; +};