Claude create settings page
This commit is contained in:
parent
1ecc1374d2
commit
6d2413a25f
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal file
@ -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`)
|
||||
@ -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<SettingsFormData>({
|
||||
first_name: user?.first_name || "",
|
||||
last_name: user?.last_name || "",
|
||||
email: user?.email || "",
|
||||
image: user?.image || "",
|
||||
});
|
||||
|
||||
return {
|
||||
formData,
|
||||
setFormData,
|
||||
loading,
|
||||
setLoading,
|
||||
user,
|
||||
};
|
||||
}
|
||||
119
src/components/pages/admin/settings/(sections)/settings-form.tsx
Normal file
119
src/components/pages/admin/settings/(sections)/settings-form.tsx
Normal file
@ -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<typeof formData, APIResponseObject>(
|
||||
`/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 (
|
||||
<Stack className="w-full max-w-[600px] items-stretch gap-6">
|
||||
<Stack className="gap-1">
|
||||
<Span size="small" variant="faded">
|
||||
Username
|
||||
</Span>
|
||||
<Span>
|
||||
<strong>{user?.username || "—"}</strong>
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row className="gap-4 items-stretch">
|
||||
<Input
|
||||
title="First Name"
|
||||
placeholder="Eg. John"
|
||||
value={formData.first_name}
|
||||
changeHandler={(v) =>
|
||||
setFormData((prev) => ({ ...prev, first_name: v }))
|
||||
}
|
||||
required
|
||||
showLabel
|
||||
/>
|
||||
<Input
|
||||
title="Last Name"
|
||||
placeholder="Eg. Doe"
|
||||
value={formData.last_name}
|
||||
changeHandler={(v) =>
|
||||
setFormData((prev) => ({ ...prev, last_name: v }))
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Input
|
||||
title="Email"
|
||||
placeholder="Eg. john@example.com"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
changeHandler={(v) =>
|
||||
setFormData((prev) => ({ ...prev, email: v }))
|
||||
}
|
||||
required
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<ImageUpload
|
||||
label="Profile Image"
|
||||
existingImageUrl={formData.image || undefined}
|
||||
onChangeHandler={(imgData) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
image: imgData?.imageBase64Full || prev.image,
|
||||
}));
|
||||
}}
|
||||
className="h-[200px]"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Save Settings"
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
20
src/components/pages/admin/settings/index.tsx
Normal file
20
src/components/pages/admin/settings/index.tsx
Normal file
@ -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 (
|
||||
<Fragment>
|
||||
<AdminHero
|
||||
title="Settings"
|
||||
description="Update your profile information"
|
||||
/>
|
||||
<Divider />
|
||||
<Stack className="grid-cell-content">
|
||||
<SettingsForm />
|
||||
</Stack>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
|
||||
16
src/pages/admin/settings.tsx
Normal file
16
src/pages/admin/settings.tsx
Normal file
@ -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 (
|
||||
<Layout>
|
||||
<Main />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
return await defaultAdminProps({ ctx });
|
||||
};
|
||||
60
src/pages/api/admin/settings.ts
Normal file
60
src/pages/api/admin/settings.ts
Normal file
@ -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<APIResponseObject>,
|
||||
) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user