From 6d2413a25f538cc6dc4b8ad320d5c206a3957585 Mon Sep 17 00:00:00 2001 From: Archben Date: Wed, 11 Mar 2026 13:28:52 +0000 Subject: [PATCH] Claude create settings page --- CLAUDE.md | 97 ++++++++++++++ .../settings/(hooks)/use-settings-form.ts | 31 +++++ .../settings/(sections)/settings-form.tsx | 119 ++++++++++++++++++ src/components/pages/admin/settings/index.tsx | 20 +++ .../deployment-users/setup-deployment-user.ts | 6 +- src/pages/admin/settings.tsx | 16 +++ src/pages/api/admin/settings.ts | 60 +++++++++ 7 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 src/components/pages/admin/settings/(hooks)/use-settings-form.ts create mode 100644 src/components/pages/admin/settings/(sections)/settings-form.tsx create mode 100644 src/components/pages/admin/settings/index.tsx create mode 100644 src/pages/admin/settings.tsx create mode 100644 src/pages/api/admin/settings.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..787d607 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`turboci-admin` is a Next.js (Pages Router) admin panel for TurboCI, a CI/CD deployment system. It manages deployments, services, and servers across cloud providers. + +## Commands + +### Development + +```bash +bun run project:init # Full first-time setup: install deps, init twui submodule, generate DB schema +bun run dev # Next.js dev server on port 3772 (web only) +bun run pm2:dev # Start all 3 processes (web + cron + websocket) via PM2 in dev/watch mode +bun run pm2:start # Start all 3 processes via PM2 in production mode +bun run pm2:kill # Kill all PM2 processes +``` + +### Build & Database + +```bash +bun run build # Production Next.js build +bun run db:schema # Regenerate SQLite types from schema (run after DB changes) +``` + +### Docker + +```bash +bun run docker:start # Rebuild and start all containers +bun run docker:logs # Tail Docker logs +``` + +### Submodule (twui) + +```bash +bun run twui:update # Pull latest twui component library +``` + +## Architecture + +### Process Model + +The app runs as **three separate processes**, managed by PM2 (dev) or Docker Compose (prod): + +| Process | Entry Point | Description | +|---|---|---| +| `turboci-web` | Next.js (port 3772) | Pages Router app + API routes | +| `turboci-websocket` | `src/websocket/index.ts` | Bun native WebSocket server (port from `WEB_SOCKET_PORT` env) | +| `turboci-cron` | `src/cron/index.ts` | Polls every 30s (`AppData.CronInterval`) to check services | + +### Database + +- **ORM**: `@moduletrace/nsqlite` — SQLite with Bun +- **Config**: `nsqlite.config.js` — DB name `turboci-admin`, stored in `src/db/` +- **Types**: Auto-generated in `src/db/types.ts` via `bun run db:schema` +- **Tables**: `users`, `users_ports` + +### Key Directories + +- `src/pages/` — Next.js pages and API routes (`/api/*`) +- `src/websocket/` — Bun WebSocket server; events are dispatched via a switch in `socket-message.ts` +- `src/cron/` — Background cron job (service health checks) +- `src/functions/` — Business logic (auth, deployment users, pages, ttyd) +- `src/utils/` — Utilities including SSH execution, config file access, port management +- `src/components/` — React components organized by page +- `src/types/index.ts` — All shared TypeScript types including `TCIConfig`, `WebSocketEvents`, `PagePropsType` +- `src/data/app-data.ts` — Central constants (`AppData`): cookie names, port ranges, timeouts +- `src/exports/client-exports.ts` — Re-exports from `@moduletrace/datasquirel` (use `_n` for number coercion, `EJSON` for WebSocket serialization) +- `twui/` — Git submodule: internal Tailwind UI component library + +### TurboCI Config Files (Runtime) + +The app reads deployment config from the host at runtime: + +- `/root/.turboci/.config/turboci.json` — Main deployment config (`TCIGlobalConfig` type) +- `/root/.turboci/.ssh/turboci` — SSH private key for connecting to deployment servers +- `/root/.turboci/deployment_id` — Current deployment identifier + +Use `grabDirNames()` (`src/utils/grab-dir-names.ts`) to get these paths, never hardcode them. + +### WebSocket Event Flow + +Client sends EJSON-serialized `WebSocketDataType` → `socket-message.ts` dispatches by `event` field → handler in `src/websocket/events/`. Connected users tracked in `global.WEBSOCKET_CONNECTED_USERS_DATA`. + +### Auth + +Cookie-based auth using `@moduletrace/datasquirel`. Cookie names are in `AppData` (`AuthCookieName`, `AuthCSRFCookieName`). Use `userAuth` (`src/utils/user-auth.ts`) in API routes and `getServerSideProps`. + +### Environment Variables + +Required in `.env` (see `test.env` for reference keys): +- `DSQL_ENCRYPTION_PASSWORD` / `DSQL_ENCRYPTION_SALT` — Used by datasquirel for auth token encryption +- `PORT` — Web server port (default 3772) +- `WEB_SOCKET_PORT` — WebSocket server port +- `HOST` — Full host URL (e.g. `http://localhost:3772`) diff --git a/src/components/pages/admin/settings/(hooks)/use-settings-form.ts b/src/components/pages/admin/settings/(hooks)/use-settings-form.ts new file mode 100644 index 0000000..3c8abf8 --- /dev/null +++ b/src/components/pages/admin/settings/(hooks)/use-settings-form.ts @@ -0,0 +1,31 @@ +import { AppContext } from "@/src/pages/_app"; +import useStatus from "@/twui/components/hooks/useStatus"; +import { useContext, useState } from "react"; + +export type SettingsFormData = { + first_name?: string; + last_name?: string; + email?: string; + image?: string; +}; + +export default function useSettingsForm() { + const { pageProps } = useContext(AppContext); + const { user } = pageProps; + const { loading, setLoading } = useStatus(); + + const [formData, setFormData] = useState({ + first_name: user?.first_name || "", + last_name: user?.last_name || "", + email: user?.email || "", + image: user?.image || "", + }); + + return { + formData, + setFormData, + loading, + setLoading, + user, + }; +} diff --git a/src/components/pages/admin/settings/(sections)/settings-form.tsx b/src/components/pages/admin/settings/(sections)/settings-form.tsx new file mode 100644 index 0000000..699c798 --- /dev/null +++ b/src/components/pages/admin/settings/(sections)/settings-form.tsx @@ -0,0 +1,119 @@ +import { AppContext } from "@/src/pages/_app"; +import fetchApi from "@/twui/components/utils/fetch/fetchApi"; +import ImageUpload from "@/twui/components/form/ImageUpload"; +import Input from "@/twui/components/form/Input"; +import Button from "@/twui/components/layout/Button"; +import Divider from "@/twui/components/layout/Divider"; +import Row from "@/twui/components/layout/Row"; +import Span from "@/twui/components/layout/Span"; +import Stack from "@/twui/components/layout/Stack"; +import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types"; +import { useContext } from "react"; +import useSettingsForm from "../(hooks)/use-settings-form"; + +export default function SettingsForm() { + const { setToast } = useContext(AppContext); + const { formData, setFormData, loading, setLoading, user } = + useSettingsForm(); + + async function handleSubmit() { + if (!formData.first_name?.match(/./)) return; + + setLoading(true); + + try { + const res = await fetchApi( + `/api/admin/settings`, + { method: "POST", body: formData }, + ); + + if (res.success) { + setToast({ + toastOpen: true, + toastMessage: "Settings saved", + toastStyle: "success", + closeDelay: 3000, + }); + window.location.reload(); + } else { + setToast({ + toastOpen: true, + toastMessage: res.msg || "Failed to save settings", + toastStyle: "error", + closeDelay: 4000, + }); + } + } finally { + setLoading(false); + } + } + + return ( + + + + Username + + + {user?.username || "—"} + + + + + + + + setFormData((prev) => ({ ...prev, first_name: v })) + } + required + showLabel + /> + + setFormData((prev) => ({ ...prev, last_name: v })) + } + showLabel + /> + + + + setFormData((prev) => ({ ...prev, email: v })) + } + required + showLabel + /> + + { + setFormData((prev) => ({ + ...prev, + image: imgData?.imageBase64Full || prev.image, + })); + }} + className="h-[200px]" + /> + + + + ); +} diff --git a/src/components/pages/admin/settings/index.tsx b/src/components/pages/admin/settings/index.tsx new file mode 100644 index 0000000..eddd7a0 --- /dev/null +++ b/src/components/pages/admin/settings/index.tsx @@ -0,0 +1,20 @@ +import AdminHero from "@/src/components/general/admin/hero"; +import Divider from "@/twui/components/layout/Divider"; +import Stack from "@/twui/components/layout/Stack"; +import { Fragment } from "react"; +import SettingsForm from "./(sections)/settings-form"; + +export default function Main() { + return ( + + + + + + + + ); +} diff --git a/src/functions/deployment-users/setup-deployment-user.ts b/src/functions/deployment-users/setup-deployment-user.ts index 3e108c8..8b2b989 100644 --- a/src/functions/deployment-users/setup-deployment-user.ts +++ b/src/functions/deployment-users/setup-deployment-user.ts @@ -46,6 +46,7 @@ export default async function setupDeploymentUser({ user_id }: Params) { cmd += `chmod 600 "${ssh_dir}/authorized_keys"\n`; cmd += `cat << 'EOF' > ${force_command_file}\n`; + cmd += `#!/bin/bash\n`; cmd += `EOF\n`; cmd += `cat << 'EOF' > ${sshd_config_file}\n`; @@ -57,7 +58,10 @@ export default async function setupDeploymentUser({ user_id }: Params) { cmd += ` # Restrict shell / tunneling\n`; cmd += ` AllowTcpForwarding yes\n`; - cmd += ` X11Forwarding no\n`; + cmd += ` X11Forwarding no\n\n`; + + cmd += ` # Restrict Commands\n`; + cmd += ` ForceCommand ${force_command_file}\n`; cmd += `EOF\n`; } diff --git a/src/pages/admin/settings.tsx b/src/pages/admin/settings.tsx new file mode 100644 index 0000000..e6bd897 --- /dev/null +++ b/src/pages/admin/settings.tsx @@ -0,0 +1,16 @@ +import Main from "@/src/components/pages/admin/settings"; +import defaultAdminProps from "@/src/functions/pages/admin/default-admin-props"; +import Layout from "@/src/layouts/admin"; +import { GetServerSideProps } from "next"; + +export default function AdminSettings() { + return ( + +
+ + ); +} + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + return await defaultAdminProps({ ctx }); +}; diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts new file mode 100644 index 0000000..534507d --- /dev/null +++ b/src/pages/api/admin/settings.ts @@ -0,0 +1,60 @@ +import loginUser from "@/src/functions/auth/login-user"; +import { NSQLITE_TURBOCI_ADMIN_USERS } from "@/src/db/types"; +import userAuth from "@/src/utils/user-auth"; +import NSQLite from "@moduletrace/nsqlite"; +import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types"; +import type { NextApiRequest, NextApiResponse } from "next"; + +type ReqBody = { + first_name?: string; + last_name?: string; + email?: string; + image?: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + try { + if (req.method !== "POST") { + return res.json({ + success: false, + msg: "Wrong Method", + }); + } + + const { singleRes: user } = await userAuth({ req }); + + if (!user?.id) { + return res.json({ + success: false, + msg: "Unauthorized", + }); + } + + const { first_name, last_name, email, image } = req.body as ReqBody; + + if (!first_name?.match(/./)) { + return res.json({ success: false, msg: "First name is required" }); + } + + const update: NSQLITE_TURBOCI_ADMIN_USERS = {}; + if (first_name) update.first_name = first_name; + if (last_name !== undefined) update.last_name = last_name; + if (email) update.email = email; + if (image !== undefined) update.image = image; + + await NSQLite.update({ + table: "users", + targetId: user.id, + data: update, + }); + + const updated = await loginUser({ res, user_id: user.id }); + + return res.json(updated); + } catch (error: any) { + return res.json({ success: false, msg: error.message }); + } +}