diff --git a/README.md b/README.md index 7c0a1024..7f33c840 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,64 @@ # Caddy Proxy Manager -[https://caddyproxymanager.com](https://caddyproxymanager.com) +An admin-only control plane for driving the Caddy admin API. Manage reverse proxies, redirects, maintenance pages, certificates, and supporting access-control lists with a modern Next.js 16 dashboard. -Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The stack is built with Next.js 16 (App Router), Material UI, and a lightweight SQLite data layer. It features simple username/password authentication, first-class Caddy admin API integration, and tooling for Cloudflare DNS challenge automation. +--- -## Highlights +## Project Status -- **Next.js 16 App Router** – server components for data loading, client components for interactivity, and a unified API surface. -- **Material UI dark mode** – fast, responsive dashboard with ready-made components and accessibility baked in. -- **Simple authentication** – environment-based username/password login configured via docker-compose. -- **End-to-end Caddy orchestration** – generate JSON for HTTP(S) proxies, redirects, 404 hosts via the Caddy admin API. -- **Cloudflare DNS challenge support** – xcaddy build bundles the `cloudflare` DNS and `layer4` modules; credentials are configurable in the UI. -- **Security-by-default** – HSTS (`Strict-Transport-Security: max-age=63072000`) applied to every managed host. -- **Embedded audit log** – every configuration change is recorded with actor, summary, and timestamp. +- **Deployment model:** single administrative user (configured via environment variables) +- **Authentication:** credentials flow rate-limited to 5 attempts / 5 minutes with a 15 minute cooldown after repeated failures +- **Authorization:** all mutating actions require admin privileges; read-only pages stay accessible to the authenticated session +- **Secrets management:** Cloudflare API tokens are accepted through the UI but never rendered back to the browser; existing tokens can be revoked explicitly +- **Known limitation:** Imported certificates are stored in SQLite without encryption (planned improvement) -## Project Structure +--- + +## Feature Highlights + +- **Next.js 16 App Router** – hybrid server/client rendering, server actions, and streaming layouts +- **Material UI** – responsive dark-themed dashboard with polished defaults +- **Caddy integration** – generates JSON and pushes it directly to the Caddy admin API for proxies, redirects, and dead/maintenance responses +- **Certificate lifecycle** – manage ACME (Cloudflare DNS-01) or import PEM certificates; certificates written to disk with restrictive permissions +- **Access control** – bcrypt-backed HTTP basic-auth lists with assignment to proxy hosts +- **Observability** – built-in audit log records actor, action, and summary for every change +- **Security defaults** – strict session secret enforcement, mandatory credential rotation in production, HSTS injection for managed hosts, login throttling, and admin-only mutations + +--- + +## Architecture Overview ``` . -├── app/ # Next.js App Router entrypoint (layouts, routes, server actions) -│ ├── (auth)/ # Login flow -│ ├── (dashboard)/ # Dashboard layout, feature surface, client renderers -│ ├── api/ # Route handlers for auth callbacks/logout -│ ├── providers.tsx # Global MUI theme + CssBaseline -│ └── layout.tsx # Root HTML/body wrapper +├── app/ # Next.js App Router (layouts, routes, server actions) +│ ├── (auth)/ # Login experience +│ ├── (dashboard)/ # Dashboard layout, feature modules, client renderers +│ ├── api/ # NextAuth handlers and health probe +│ ├── globals.css # Global styles +│ └── providers.tsx # MUI theme provider ├── src/ -│ └── lib/ # SQLite integration, models, Caddy config builder +│ └── lib/ # Prisma client, domain models, Caddy config builder, helpers +├── prisma/ # Prisma schema ├── docker/ -│ ├── web/ # Next.js production image (standalone output) +│ ├── web/ # Next.js production image │ └── caddy/ # xcaddy build with Cloudflare + layer4 modules -├── docker-compose.yml # Multi-container deployment (Next.js app + Caddy) -├── data/ # Generated at runtime (SQLite DB, cert storage, Caddy state) -└── README.md # You are here +├── docker-compose.yml # Sample two-container deployment (Next.js + Caddy) +└── data/ # Runtime SQLite database and certificate output ``` -### Dashboard Modules +### Dashboard Surface -- `ProxyHostsClient.tsx` – create/update/delete HTTP(S) reverse proxies, assign certs/access lists. -- `RedirectsClient.tsx` – manage 301/302 redirects with optional path/query preservation. -- `DeadHostsClient.tsx` – serve custom offline pages with programmable status codes. -- `AccessListsClient.tsx` – manage HTTP basic auth credentials and membership. -- `CertificatesClient.tsx` – import PEMs or request managed ACME certificates. -- `SettingsClient.tsx` – general metadata and Cloudflare DNS token configuration. -- `AuditLogClient.tsx` – list chronological administrative activity. +| Module | Purpose | +| --- | --- | +| Proxy Hosts | Configure HTTP(S) reverse proxies, upstream pools, headers, and Authentik forward auth support | +| Redirects | Define 301/302 redirects with optional query preservation | +| Dead Hosts | Serve branded maintenance responses with custom status codes | +| Access Lists | Create & assign HTTP basic-auth user lists | +| Certificates | Request ACME-managed or import custom PEM certificates | +| Settings | Configure primary domain, ACME email, and Cloudflare DNS automation | +| Audit Log | Review a chronological feed of administrative actions | -## Feature Overview - -### Authentication & Authorization -- Simple username/password authentication configured via environment variables. -- Credentials set in docker-compose or `.env` file. -- Session persistence via signed JWT tokens. - -### Reverse Proxy Management -- HTTP(S) proxy hosts with TLS enforcement, WebSocket + HTTP/2 toggles. -- Redirect hosts with custom status codes and query preservation. -- Dead/maintenance hosts with custom responses. -- Access list (basic auth) integration for protected hosts. -- TLS certificate lifecycle: managed ACME (DNS-01 via Cloudflare) or imported PEMs. - -### Operations & Observability -- Full audit log with actor/action/summary/time. -- One-click revalidation of Caddy configuration after mutations. -- Migrations run automatically on startup; upgrades are seamless. -- Docker-first deployment, HSTS defaults, Cloudflare DNS automation. - -## Requirements - -- Node.js 20+ (development) -- Docker + Docker Compose v2 (deployment) -- Optional: Cloudflare DNS API token for automated certificate issuance +--- ## Quick Start @@ -80,112 +70,125 @@ Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse npm install ``` -2. **Create environment file** +2. **Configure environment** ```bash cp .env.example .env ``` - Edit `.env` and set your admin credentials: + Set secure values: ```env - ADMIN_USERNAME=your-username - ADMIN_PASSWORD=your-secure-password - SESSION_SECRET=your-random-secret-here + ADMIN_USERNAME=your-admin + ADMIN_PASSWORD=your-strong-password + SESSION_SECRET=$(openssl rand -base64 32) ``` -3. **Run the development server** +3. **Run Prisma client generation (optional in dev)** + + ```bash + npx prisma generate + ``` + +4. **Start the dev server** ```bash npm run dev ``` -4. **Login** +5. **Login** - - Visit `http://localhost:3000/login` - - Enter your configured username and password - - You're now logged in as administrator + - Navigate to `http://localhost:3000/login` + - Enter the configured credentials (remember that failed attempts are throttled) -5. **Configure Cloudflare DNS (optional)** +### Docker Compose - - Navigate to **Settings → Cloudflare DNS**. - - Provide an API token with `Zone.DNS:Edit` scope and the relevant zone/account IDs. - - Any managed certificates attached to hosts will now request TLS via DNS validation. +The bundled `docker-compose.yml` spins up: -### Production Deployment - -## Docker Compose - -`docker-compose.yml` defines a two-container stack: - -- `web`: Next.js server with SQLite database and certificate store in `/data`. -- `caddy`: xcaddy-built binary with Cloudflare DNS provider and layer4 modules. - -Launch the stack: +- `web`: Next.js standalone output (Node 20) with SQLite in `/app/data` +- `caddy`: xcaddy-built binary with Cloudflare DNS & layer4 modules enabled ```bash -# Create .env file with your credentials -cp .env.example .env - -# Edit .env and set secure values for: -# - ADMIN_USERNAME -# - ADMIN_PASSWORD -# - SESSION_SECRET - -# Start the containers +cp .env.example .env # set ADMIN_*/SESSION_SECRET values docker compose up -d ``` -### Environment Variables +Volumes: -**Required (Security):** +- `./data` → `/app/data` (SQLite database & imported cert material) +- `./caddy-data` (Caddy ACME storage) +- `./caddy-config` (Caddy runtime config state) -- `SESSION_SECRET`: Random 32+ character string used to sign session tokens. Generate with: `openssl rand -base64 32` -- `ADMIN_USERNAME`: Username for admin login (default: `admin`) -- `ADMIN_PASSWORD`: Password for admin login (default: `admin`) +--- -**Optional (Application):** +## Configuration Reference -- `BASE_URL`: Public base URL for the application (default: `http://localhost:3000`) -- `PRIMARY_DOMAIN`: Default domain served by Caddy (default: `caddyproxymanager.com`) -- `CADDY_API_URL`: URL for the Caddy admin API (default: `http://caddy:2019`) -- `DATABASE_PATH`: Path to the SQLite database (default: `/app/data/caddy-proxy-manager.db`) +| Variable | Description | Default | +| --- | --- | --- | +| `ADMIN_USERNAME` | Admin login username | `admin` (development only) | +| `ADMIN_PASSWORD` | Admin login password | `admin` (development only) | +| `SESSION_SECRET` | 32+ char string for JWT/session signing | _required_ | +| `BASE_URL` | Public URL for the dashboard | `http://localhost:3000` | +| `CADDY_API_URL` | Internal Caddy admin API endpoint | `http://caddy:2019` (production container) | +| `DATABASE_PATH` | SQLite file path | `/app/data/caddy-proxy-manager.db` | +| `PRIMARY_DOMAIN` | Default domain for generated Caddy config | `caddyproxymanager.com` | -⚠️ **Important**: Always change the default `ADMIN_USERNAME` and `ADMIN_PASSWORD` in production! +⚠️ **Production deployments must override `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `SESSION_SECRET`.** -## Data Locations +--- -- `data/caddy-proxy-manager.db`: SQLite database storing configuration, sessions, and audit log. -- `data/certs/`: Imported TLS certificates and keys generated by the UI. -- `caddy-data/`: Autogenerated Caddy state (ACME storage, etc.). -- `caddy-config/`: Caddy configuration storage. +## Cloudflare DNS Automation -## UI Features +- Provide a Cloudflare API token with `Zone.DNS:Edit` permissions. +- The token field is rendered as a password input and never pre-filled. +- To revoke a stored token, select **Remove existing token** and submit. +- Zone ID / Account ID fields are optional but recommended for multi-zone setups. +- Managed certificates rely on valid credentials; otherwise, import PEMs manually. -- **Proxy Hosts:** HTTP(S) reverse proxies with HSTS, access lists, optional custom certificates, and WebSocket support. Hosts stay offline until a certificate (imported or managed with Cloudflare automation) is linked. -- **Redirects:** 301/302 responses with optional path/query preservation. -- **Dead Hosts:** Branded responses for offline services. -- **Access Lists:** Bcrypt-backed basic auth credentials, assignable to proxy hosts. -- **Certificates:** Managed (ACME) or imported PEM certificates with audit history. -- **Audit Log:** Chronological record of every configuration change and actor. -- **Settings:** General metadata and Cloudflare DNS credentials. +--- + +## Security Posture + +- **Session Secret Enforcement:** Production boots only when `SESSION_SECRET` is strong and not a known placeholder. +- **Admin Credential Guardrails:** Default credentials rejected at runtime; production requires 12+ char password with letters & numbers. +- **Login Throttling:** Per-IP+username throttling (5 attempts / 5 minutes, 15 minute lockout). +- **Admin-Only Mutations:** All server actions that modify state require `requireAdmin()`. +- **Certificate Handling:** Imported certificates and keys are projected to disk with `0600` permissions; certificate directory forced to `0700`. _Note: database storage for imported key material is not yet encrypted._ +- **Audit Trail:** Every mutation logs actor/action/summary to SQLite. +- **Transport Security:** HSTS (`Strict-Transport-Security: max-age=63072000`) applied to managed hosts by default. + +--- + +## Scripts + +| Command | Purpose | +| --- | --- | +| `npm run dev` | Start Next.js in development mode | +| `npm run build` | Create production build | +| `npm start` | Run the production output | +| `npm run typecheck` | TypeScript project check | + +> `npm run lint` is intentionally omitted until the lint pipeline is finalized for this workspace. + +--- ## Development Notes -- SQLite schema migrations are embedded and run automatically on startup via Prisma. -- Caddy configuration is rebuilt on every change and pushed via the admin API. Failures are surfaced to the UI. -- Authentication uses NextAuth.js with JWT session strategy. -- Type checking: `npm run typecheck` -- Build: `npm run build` +- Prisma manages the SQLite schema. `npx prisma db push` runs during builds and entrypoint start. +- Caddy configuration is rebuilt on each mutation and pushed via the admin API; failures are surfaced to the UI with actionable errors. +- Login throttling state is kept in-memory per Node process; scale-out deployments should front the app with a shared cache (Redis/Memcached) for consistent limiting across replicas. +- The project currently supports a single administrator; introducing multi-role access will require revisiting authorization logic. -## Security Considerations +--- -1. **Change default credentials**: Never use `admin/admin` in production -2. **Use strong SESSION_SECRET**: Generate with `openssl rand -base64 32` -3. **Use HTTPS in production**: Configure BASE_URL with `https://` protocol -4. **Restrict network access**: Ensure port 3000 is only accessible via reverse proxy -5. **Keep updated**: Regularly update dependencies and Docker images -6. **Provide Cloudflare credentials before using managed certificates**: The built-in automation only issues TLS certificates when valid Cloudflare API credentials are configured; otherwise, services (including `PRIMARY_DOMAIN`) remain on HTTP or require imported certificates. +## Roadmap & Known Gaps + +- Encrypt imported certificate material before persistence. +- Shared rate-limiting store for horizontally scaled deployments. +- Non-admin roles with scoped permissions. +- Additional DNS providers for managed certificates. + +--- ## License -MIT License © Caddy Proxy Manager contributors. +MIT License © Caddy Proxy Manager contributors diff --git a/app/(auth)/login/LoginClient.tsx b/app/(auth)/login/LoginClient.tsx index 51221bb8..234f61f4 100644 --- a/app/(auth)/login/LoginClient.tsx +++ b/app/(auth)/login/LoginClient.tsx @@ -32,8 +32,18 @@ export default function LoginClient() { password }); - if (!result || result.error) { - setLoginError("Invalid username or password."); + if (!result || result.error || result.ok === false) { + let message: string | null = null; + + if (result?.status === 429) { + message = result.error && result.error !== "CredentialsSignin" + ? result.error + : "Too many login attempts. Try again in a few minutes."; + } else if (result?.error && result.error !== "CredentialsSignin") { + message = result.error; + } + + setLoginError(message ?? "Invalid username or password."); setLoginPending(false); return; } diff --git a/app/(dashboard)/access-lists/actions.ts b/app/(dashboard)/access-lists/actions.ts index 481039ad..6a44bde8 100644 --- a/app/(dashboard)/access-lists/actions.ts +++ b/app/(dashboard)/access-lists/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; import { addAccessListEntry, createAccessList, @@ -11,9 +11,8 @@ import { } from "@/src/lib/models/access-lists"; export async function createAccessListAction(formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); const rawUsers = String(formData.get("users") ?? ""); const accounts = rawUsers .split("\n") @@ -37,9 +36,8 @@ export async function createAccessListAction(formData: FormData) { } export async function updateAccessListAction(id: number, formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await updateAccessList( id, { @@ -52,17 +50,15 @@ export async function updateAccessListAction(id: number, formData: FormData) { } export async function deleteAccessListAction(id: number) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await deleteAccessList(id, userId); revalidatePath("/access-lists"); } export async function addAccessEntryAction(id: number, formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await addAccessListEntry( id, { @@ -75,9 +71,8 @@ export async function addAccessEntryAction(id: number, formData: FormData) { } export async function deleteAccessEntryAction(accessListId: number, entryId: number) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await removeAccessListEntry(accessListId, entryId, userId); revalidatePath("/access-lists"); } diff --git a/app/(dashboard)/certificates/actions.ts b/app/(dashboard)/certificates/actions.ts index 107d3f89..6210f192 100644 --- a/app/(dashboard)/certificates/actions.ts +++ b/app/(dashboard)/certificates/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; import { createCertificate, deleteCertificate, updateCertificate } from "@/src/lib/models/certificates"; function parseDomains(value: FormDataEntryValue | null): string[] { @@ -16,9 +16,8 @@ function parseDomains(value: FormDataEntryValue | null): string[] { } export async function createCertificateAction(formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); const type = String(formData.get("type") ?? "managed") as "managed" | "imported"; await createCertificate( { @@ -35,9 +34,8 @@ export async function createCertificateAction(formData: FormData) { } export async function updateCertificateAction(id: number, formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); const type = formData.get("type") ? (String(formData.get("type")) as "managed" | "imported") : undefined; await updateCertificate( id, @@ -55,9 +53,8 @@ export async function updateCertificateAction(id: number, formData: FormData) { } export async function deleteCertificateAction(id: number) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await deleteCertificate(id, userId); revalidatePath("/certificates"); } diff --git a/app/(dashboard)/dead-hosts/actions.ts b/app/(dashboard)/dead-hosts/actions.ts index ef91b2fb..0e22f171 100644 --- a/app/(dashboard)/dead-hosts/actions.ts +++ b/app/(dashboard)/dead-hosts/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; import { createDeadHost, deleteDeadHost, updateDeadHost } from "@/src/lib/models/dead-hosts"; function parseDomains(value: FormDataEntryValue | null): string[] { @@ -16,9 +16,8 @@ function parseDomains(value: FormDataEntryValue | null): string[] { } export async function createDeadHostAction(formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await createDeadHost( { name: String(formData.get("name") ?? "Dead host"), @@ -33,9 +32,8 @@ export async function createDeadHostAction(formData: FormData) { } export async function updateDeadHostAction(id: number, formData: FormData) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await updateDeadHost( id, { @@ -51,9 +49,8 @@ export async function updateDeadHostAction(id: number, formData: FormData) { } export async function deleteDeadHostAction(id: number) { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await deleteDeadHost(id, userId); revalidatePath("/dead-hosts"); } diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 29712aa2..5b46cb6a 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions"; import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput } from "@/src/lib/models/proxy-hosts"; @@ -79,9 +79,8 @@ export async function createProxyHostAction( formData: FormData ): Promise { try { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await createProxyHost( { name: String(formData.get("name") ?? "Untitled"), @@ -112,9 +111,8 @@ export async function updateProxyHostAction( formData: FormData ): Promise { try { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); const boolField = (key: string) => (formData.has(`${key}_present`) ? parseCheckbox(formData.get(key)) : undefined); await updateProxyHost( id, @@ -150,9 +148,8 @@ export async function deleteProxyHostAction( _prevState: ActionState = INITIAL_ACTION_STATE ): Promise { try { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await deleteProxyHost(id, userId); revalidatePath("/proxy-hosts"); return actionSuccess("Proxy host deleted."); diff --git a/app/(dashboard)/redirects/actions.ts b/app/(dashboard)/redirects/actions.ts index 172b57f6..6d68fb76 100644 --- a/app/(dashboard)/redirects/actions.ts +++ b/app/(dashboard)/redirects/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { requireUser } from "@/src/lib/auth"; +import { requireAdmin } from "@/src/lib/auth"; import { createRedirectHost, deleteRedirectHost, updateRedirectHost } from "@/src/lib/models/redirect-hosts"; import { actionSuccess, actionError, type ActionState } from "@/src/lib/actions"; @@ -18,9 +18,8 @@ function parseList(value: FormDataEntryValue | null): string[] { export async function createRedirectAction(_prevState: ActionState, formData: FormData): Promise { try { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await createRedirectHost( { name: String(formData.get("name") ?? "Redirect"), @@ -41,9 +40,8 @@ export async function createRedirectAction(_prevState: ActionState, formData: Fo export async function updateRedirectAction(id: number, _prevState: ActionState, formData: FormData): Promise { try { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await updateRedirectHost( id, { @@ -65,9 +63,8 @@ export async function updateRedirectAction(id: number, _prevState: ActionState, export async function deleteRedirectAction(id: number, _prevState: ActionState): Promise { try { - const session = await requireUser(); - const user = session.user; - const userId = Number(user.id); + const session = await requireAdmin(); + const userId = Number(session.user.id); await deleteRedirectHost(id, userId); revalidatePath("/redirects"); return actionSuccess("Redirect deleted successfully"); diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index 49e8d9e3..4903960e 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -1,8 +1,8 @@ "use client"; import { useFormState } from "react-dom"; -import { Alert, Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material"; -import type { CloudflareSettings, GeneralSettings } from "@/src/lib/settings"; +import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; +import type { GeneralSettings } from "@/src/lib/settings"; import { updateCloudflareSettingsAction, updateGeneralSettingsAction @@ -10,7 +10,11 @@ import { type Props = { general: GeneralSettings | null; - cloudflare: CloudflareSettings | null; + cloudflare: { + hasToken: boolean; + zoneId?: string; + accountId?: string; + }; }; export default function SettingsClient({ general, cloudflare }: Props) { @@ -68,15 +72,32 @@ export default function SettingsClient({ general, cloudflare }: Props) { Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates. + {cloudflare.hasToken && ( + + A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it. + + )} {cloudflareState?.message && ( {cloudflareState.message} )} - - - + + } + label="Remove existing token" + disabled={!cloudflare.hasToken} + /> + +