From f775e5c80c633966eeb4cf32037df9f49aa90570 Mon Sep 17 00:00:00 2001 From: chunzhimoe <60135925+chunzhimoe@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:40:14 +0800 Subject: [PATCH] feat: zone dropdown in create form, fix light mode colors, restructure .env.example --- .env.example | 58 +++++--- src/app/api/zones/route.ts | 14 ++ src/app/create/page.tsx | 278 ++++++++++++++++++++----------------- src/lib/cloudflare.ts | 10 ++ 4 files changed, 208 insertions(+), 152 deletions(-) create mode 100644 src/app/api/zones/route.ts diff --git a/.env.example b/.env.example index 157577f..0e1bbe7 100644 --- a/.env.example +++ b/.env.example @@ -1,29 +1,41 @@ -# Cloudflare credentials (server-side only, never sent to browser) -CLOUDFLARE_ACCOUNT_ID=your_account_id_here -CLOUDFLARE_API_TOKEN=your_api_token_here +# ═══════════════════════════════════════════════════════════════ +# SSH Launcher — Environment Variables +# Copy to .env.local for local dev, or paste into Vercel dashboard +# (Settings → Environment Variables → Import .env) +# ═══════════════════════════════════════════════════════════════ -# Required API Token permissions: -# - Access: Apps and Policies Write (list + create Access apps) -# - Cloudflare Tunnel: Edit (create / configure / delete tunnels) -# - DNS: Edit (create CNAME records) -# - Zone: Read (lookup zone ID for a hostname) - -# Optional: Cloudflare Access Service Token for fetching server metrics -# (Create one in Zero Trust > Settings > Service Auth) -CF_SERVICE_CLIENT_ID= -CF_SERVICE_CLIENT_SECRET= - -# Upstash Redis — stores per-user CF credentials (multi-user) -# Create a free database at https://console.upstash.com -UPSTASH_REDIS_URL=https://your-db.upstash.io -UPSTASH_REDIS_TOKEN=your_upstash_token_here - -# GitHub OAuth (create at https://github.com/settings/developers) -# Callback URL: https://your-domain.vercel.app/api/auth/callback/github +# ── GitHub OAuth ───────────────────────────────────────────── +# Create at https://github.com/settings/developers → New OAuth App +# Homepage URL: https://your-domain.vercel.app +# Callback URL: https://your-domain.vercel.app/api/auth/callback/github GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# Required for next-auth session encryption (run: openssl rand -base64 32) +# ── NextAuth ───────────────────────────────────────────────── +# Generate secret: openssl rand -base64 32 NEXTAUTH_SECRET= -# Optional on Vercel — set to your production URL if self-hosting +# Required only when self-hosting (Vercel sets this automatically) # NEXTAUTH_URL=https://your-domain.vercel.app + +# ── Upstash Redis ───────────────────────────────────────────── +# Stores per-user Cloudflare credentials (multi-user) +# Create a free database at https://console.upstash.com +# Copy the REST API URL and Token from the database dashboard +UPSTASH_REDIS_URL= +UPSTASH_REDIS_TOKEN= + +# ── Cloudflare API (optional — can set per-user via /settings) ─ +# If set here, all users share these credentials (single-user mode) +# Required Token permissions: +# Account → Cloudflare Tunnel: Edit +# Account → Access: Apps and Policies: Edit +# Zone → DNS: Edit +# Zone → Zone: Read +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= + +# ── Cloudflare Access Service Token (optional) ─────────────── +# Used to fetch server metrics through Access-protected tunnels +# Create in Zero Trust → Access → Service Auth → Service Tokens +CF_SERVICE_CLIENT_ID= +CF_SERVICE_CLIENT_SECRET= diff --git a/src/app/api/zones/route.ts b/src/app/api/zones/route.ts new file mode 100644 index 0000000..936e632 --- /dev/null +++ b/src/app/api/zones/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { listZones } from "@/lib/cloudflare"; + +export async function GET() { + try { + const zones = await listZones(); + return NextResponse.json(zones); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to fetch zones" }, + { status: 500 } + ); + } +} diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 63a2031..db1efb7 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Rocket, CheckCircle2, @@ -15,6 +15,8 @@ import { Mail, Terminal, Hash, + ChevronDown, + RefreshCw, } from "lucide-react"; interface StepResult { @@ -31,6 +33,11 @@ interface SetupResult { launchUrl: string | null; } +interface Zone { + id: string; + name: string; +} + const STEP_LABELS: Record = { find_zone: "Lookup DNS Zone", create_tunnel: "Create Tunnel", @@ -45,21 +52,53 @@ const STEP_LABELS: Record = { export default function CreatePage() { const [form, setForm] = useState({ serverName: "", - hostname: "", + subdomain: "", + zoneId: "", sshPort: "22", ssoEmail: "", allowedEmails: "", }); + const [zones, setZones] = useState([]); + const [zonesLoading, setZonesLoading] = useState(true); + const [zonesError, setZonesError] = useState(null); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const selectedZone = zones.find((z) => z.id === form.zoneId); + const hostname = + form.subdomain && selectedZone + ? `${form.subdomain}.${selectedZone.name}` + : ""; + + async function fetchZones() { + setZonesLoading(true); + setZonesError(null); + try { + const res = await fetch("/api/zones"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load zones"); + setZones(data); + if (data.length === 1) setForm((f) => ({ ...f, zoneId: data[0].id })); + } catch (e) { + setZonesError(e instanceof Error ? e.message : "Failed to load zones"); + } finally { + setZonesLoading(false); + } + } + + useEffect(() => { fetchZones(); }, []); + const canSubmit = - form.serverName.trim() && form.hostname.trim() && form.ssoEmail.trim(); + form.serverName.trim() && + hostname.trim() && + form.ssoEmail.trim() && + !loading; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + if (!canSubmit) return; setLoading(true); setResult(null); setError(null); @@ -68,7 +107,7 @@ export default function CreatePage() { const res = await fetch("/api/setup", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form), + body: JSON.stringify({ ...form, hostname }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); @@ -87,119 +126,114 @@ export default function CreatePage() { setTimeout(() => setCopied(false), 2000); } - const set = (key: string) => (e: React.ChangeEvent) => - setForm((f) => ({ ...f, [key]: e.target.value })); + const set = (key: string) => ( + e: React.ChangeEvent + ) => setForm((f) => ({ ...f, [key]: e.target.value })); return (
-
-
+
+
{/* Header */} -
-
- +
+
+
-

Deploy SSH Host

-

+

Deploy SSH Host

+

One form → Tunnel + DNS + Access App + Install Command

{/* Form */} -
- } - label="Server Name" - hint="A friendly label for this server, e.g. prod-web-1" - > - + + } label="Server Name" + hint="A friendly label for this server, e.g. prod-web-1"> + - } - label="SSH Hostname" - hint="The domain to access this server, e.g. ssh.example.com" - > - + {/* SSH Hostname — zone dropdown + subdomain */} + } label="SSH Hostname" + hint={hostname ? `Will create: ${hostname}` : "Select a zone, then enter a subdomain"}> +
+
+ +
+ . +
+ {zonesLoading ? ( +
+ + Loading zones… +
+ ) : zonesError ? ( +
+ {zonesError} + +
+ ) : ( + <> + + + + )} +
+
- } - label="SSH Port" - hint="Server-side SSH port (usually 22)" - > - + } label="SSH Port" + hint="Server-side SSH port (usually 22)"> + - } - label="SSO Email" - hint="Your Cloudflare SSO email — its prefix becomes the Linux user" - > - + } label="SSO Email" + hint="Your Cloudflare SSO email — its prefix becomes the Linux user"> + - } - label="Allowed Emails (optional)" - hint="Comma-separated list of emails for the Access Policy. Leave empty = any authenticated user" - > -