feat: zone dropdown in create form, fix light mode colors, restructure .env.example

This commit is contained in:
chunzhimoe 2026-04-12 16:40:14 +08:00
parent 948fe3935e
commit f775e5c80c
4 changed files with 208 additions and 152 deletions

View File

@ -1,29 +1,41 @@
# Cloudflare credentials (server-side only, never sent to browser) # ═══════════════════════════════════════════════════════════════
CLOUDFLARE_ACCOUNT_ID=your_account_id_here # SSH Launcher — Environment Variables
CLOUDFLARE_API_TOKEN=your_api_token_here # Copy to .env.local for local dev, or paste into Vercel dashboard
# (Settings → Environment Variables → Import .env)
# ═══════════════════════════════════════════════════════════════
# Required API Token permissions: # ── GitHub OAuth ─────────────────────────────────────────────
# - Access: Apps and Policies Write (list + create Access apps) # Create at https://github.com/settings/developers → New OAuth App
# - Cloudflare Tunnel: Edit (create / configure / delete tunnels) # Homepage URL: https://your-domain.vercel.app
# - DNS: Edit (create CNAME records) # Callback URL: https://your-domain.vercel.app/api/auth/callback/github
# - 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_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
# Required for next-auth session encryption (run: openssl rand -base64 32) # ── NextAuth ─────────────────────────────────────────────────
# Generate secret: openssl rand -base64 32
NEXTAUTH_SECRET= 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 # 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=

View File

@ -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 }
);
}
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { import {
Rocket, Rocket,
CheckCircle2, CheckCircle2,
@ -15,6 +15,8 @@ import {
Mail, Mail,
Terminal, Terminal,
Hash, Hash,
ChevronDown,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
interface StepResult { interface StepResult {
@ -31,6 +33,11 @@ interface SetupResult {
launchUrl: string | null; launchUrl: string | null;
} }
interface Zone {
id: string;
name: string;
}
const STEP_LABELS: Record<string, string> = { const STEP_LABELS: Record<string, string> = {
find_zone: "Lookup DNS Zone", find_zone: "Lookup DNS Zone",
create_tunnel: "Create Tunnel", create_tunnel: "Create Tunnel",
@ -45,21 +52,53 @@ const STEP_LABELS: Record<string, string> = {
export default function CreatePage() { export default function CreatePage() {
const [form, setForm] = useState({ const [form, setForm] = useState({
serverName: "", serverName: "",
hostname: "", subdomain: "",
zoneId: "",
sshPort: "22", sshPort: "22",
ssoEmail: "", ssoEmail: "",
allowedEmails: "", allowedEmails: "",
}); });
const [zones, setZones] = useState<Zone[]>([]);
const [zonesLoading, setZonesLoading] = useState(true);
const [zonesError, setZonesError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [result, setResult] = useState<SetupResult | null>(null); const [result, setResult] = useState<SetupResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false); 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 = 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) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!canSubmit) return;
setLoading(true); setLoading(true);
setResult(null); setResult(null);
setError(null); setError(null);
@ -68,7 +107,7 @@ export default function CreatePage() {
const res = await fetch("/api/setup", { const res = await fetch("/api/setup", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(form), body: JSON.stringify({ ...form, hostname }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
@ -87,119 +126,114 @@ export default function CreatePage() {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => const set = (key: string) => (
setForm((f) => ({ ...f, [key]: e.target.value })); e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => setForm((f) => ({ ...f, [key]: e.target.value }));
return ( return (
<div className="min-h-[calc(100vh-3.5rem)]"> <div className="min-h-[calc(100vh-3.5rem)]">
<div className="fixed inset-0 -z-10"> <div className="fixed inset-0 -z-10 pointer-events-none">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_-10%,rgba(16,185,129,0.08),transparent)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_-10%,var(--accent-dim),transparent)]" />
</div> </div>
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12"> <div className="max-w-2xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-[var(--accent-dim)] border border-[var(--accent-border)] flex items-center justify-center shrink-0">
<Rocket className="w-5 h-5 text-emerald-400" /> <Rocket className="w-5 h-5 text-emerald-500" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white">Deploy SSH Host</h1> <h1 className="text-2xl font-bold text-[var(--text)]">Deploy SSH Host</h1>
<p className="text-sm text-zinc-500"> <p className="text-sm text-[var(--text-muted)]">
One form Tunnel + DNS + Access App + Install Command One form Tunnel + DNS + Access App + Install Command
</p> </p>
</div> </div>
</div> </div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="mt-8 space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<Field <Field icon={<Server className="w-4 h-4" />} label="Server Name"
icon={<Server className="w-4 h-4" />} hint="A friendly label for this server, e.g. prod-web-1">
label="Server Name" <input type="text" value={form.serverName} onChange={set("serverName")}
hint="A friendly label for this server, e.g. prod-web-1" placeholder="prod-web-1" className="form-input" required />
>
<input
type="text"
value={form.serverName}
onChange={set("serverName")}
placeholder="prod-web-1"
className="form-input"
required
/>
</Field> </Field>
<Field {/* SSH Hostname — zone dropdown + subdomain */}
icon={<Globe className="w-4 h-4" />} <Field icon={<Globe className="w-4 h-4" />} label="SSH Hostname"
label="SSH Hostname" hint={hostname ? `Will create: ${hostname}` : "Select a zone, then enter a subdomain"}>
hint="The domain to access this server, e.g. ssh.example.com" <div className="flex gap-2">
> <div className="flex-1 min-w-0">
<input <input
type="text" type="text"
value={form.hostname} value={form.subdomain}
onChange={set("hostname")} onChange={set("subdomain")}
placeholder="ssh.example.com" placeholder="ssh"
className="form-input" className="form-input"
required required
/> />
</div>
<span className="flex items-center text-[var(--text-faint)] text-sm font-mono select-none">.</span>
<div className="flex-[2] min-w-0 relative">
{zonesLoading ? (
<div className="form-input flex items-center gap-2 text-[var(--text-faint)]">
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
<span className="text-sm">Loading zones</span>
</div>
) : zonesError ? (
<div className="form-input flex items-center gap-2">
<span className="text-sm text-red-500 truncate flex-1">{zonesError}</span>
<button type="button" onClick={fetchZones} className="shrink-0 text-[var(--text-muted)] hover:text-[var(--text)]">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
) : (
<>
<select
value={form.zoneId}
onChange={set("zoneId")}
className="form-input appearance-none pr-8"
required
>
<option value="">Select zone</option>
{zones.map((z) => (
<option key={z.id} value={z.id}>{z.name}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--text-faint)]" />
</>
)}
</div>
</div>
</Field> </Field>
<Field <Field icon={<Hash className="w-4 h-4" />} label="SSH Port"
icon={<Hash className="w-4 h-4" />} hint="Server-side SSH port (usually 22)">
label="SSH Port" <input type="number" value={form.sshPort} onChange={set("sshPort")}
hint="Server-side SSH port (usually 22)" placeholder="22" className="form-input w-28" />
>
<input
type="number"
value={form.sshPort}
onChange={set("sshPort")}
placeholder="22"
className="form-input w-24"
/>
</Field> </Field>
<Field <Field icon={<Mail className="w-4 h-4" />} label="SSO Email"
icon={<Mail className="w-4 h-4" />} hint="Your Cloudflare SSO email — its prefix becomes the Linux user">
label="SSO Email" <input type="email" value={form.ssoEmail} onChange={set("ssoEmail")}
hint="Your Cloudflare SSO email — its prefix becomes the Linux user" placeholder="admin@example.com" className="form-input" required />
>
<input
type="email"
value={form.ssoEmail}
onChange={set("ssoEmail")}
placeholder="admin@example.com"
className="form-input"
required
/>
</Field> </Field>
<Field <Field icon={<Mail className="w-4 h-4" />} label="Allowed Emails (optional)"
icon={<Mail className="w-4 h-4" />} hint="Comma-separated emails for the Access Policy. Leave empty = any authenticated user">
label="Allowed Emails (optional)" <textarea value={form.allowedEmails} onChange={set("allowedEmails")}
hint="Comma-separated list of emails for the Access Policy. Leave empty = any authenticated user"
>
<textarea
value={form.allowedEmails}
onChange={set("allowedEmails")}
placeholder="alice@example.com, bob@example.com" placeholder="alice@example.com, bob@example.com"
rows={2} rows={2} className="form-input resize-none" />
className="form-input resize-none"
/>
</Field> </Field>
<button <button
type="submit" type="submit"
disabled={!canSubmit || loading} disabled={!canSubmit}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-semibold py-3 px-4 transition" className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 transition"
> >
{loading ? ( {loading ? (
<> <><Loader2 className="w-4 h-4 animate-spin" />Deploying</>
<Loader2 className="w-4 h-4 animate-spin" />
Deploying...
</>
) : ( ) : (
<> <><Rocket className="w-4 h-4" />Deploy Now</>
<Rocket className="w-4 h-4" />
Deploy Now
</>
)} )}
</button> </button>
</form> </form>
@ -209,39 +243,31 @@ export default function CreatePage() {
<div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/5 p-4 flex items-start gap-3"> <div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/5 p-4 flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" /> <XCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-red-300">Deploy failed</p> <p className="text-sm font-semibold text-red-500">Deploy failed</p>
<p className="text-sm text-red-400/80 mt-1 break-words">{error}</p> <p className="text-sm text-red-400 mt-1 break-words">{error}</p>
</div> </div>
</div> </div>
)} )}
{/* Results */} {/* Results */}
{result && ( {result && (
<div className="mt-8 space-y-6"> <div className="mt-8 space-y-5">
{/* Steps */} {/* Steps */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 overflow-hidden"> <div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800 text-sm font-medium text-zinc-400"> <div className="px-5 py-3 border-b border-[var(--border)] text-sm font-medium text-[var(--text-muted)]">
Pipeline Steps Pipeline Steps
</div> </div>
<div className="divide-y divide-zinc-800/50"> <div className="divide-y divide-[var(--border-subtle)]">
{result.steps.map((s, i) => ( {result.steps.map((s, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3"> <div key={i} className="flex items-center gap-3 px-5 py-3">
{s.status === "success" && ( {s.status === "success" && <CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0" />}
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" /> {s.status === "error" && <XCircle className="w-4 h-4 text-red-500 shrink-0" />}
)} {s.status === "skipped" && <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />}
{s.status === "error" && (
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
)}
{s.status === "skipped" && (
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="text-sm font-medium text-zinc-300"> <span className="text-sm font-medium text-[var(--text)]">
{STEP_LABELS[s.step] || s.step} {STEP_LABELS[s.step] || s.step}
</span> </span>
<p className="text-xs text-zinc-500 truncate"> <p className="text-xs text-[var(--text-muted)] truncate">{s.message}</p>
{s.message}
</p>
</div> </div>
</div> </div>
))} ))}
@ -250,36 +276,28 @@ export default function CreatePage() {
{/* Install command */} {/* Install command */}
{result.installCommand && ( {result.installCommand && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 overflow-hidden"> <div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between"> <div className="px-5 py-3 border-b border-[var(--border)] flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-zinc-400"> <div className="flex items-center gap-2 text-sm font-medium text-[var(--text-muted)]">
<Terminal className="w-4 h-4" /> <Terminal className="w-4 h-4" />
Install Command Install Command
</div> </div>
<button <button
onClick={copyCommand} onClick={copyCommand}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition bg-zinc-800 hover:bg-zinc-700 text-zinc-400" className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition bg-[var(--bg-subtle)] hover:bg-[var(--bg-card)] border border-[var(--border)] text-[var(--text-muted)]"
> >
{copied ? ( {copied ? (
<> <><Check className="w-3 h-3 text-emerald-500" />Copied</>
<Check className="w-3 h-3 text-emerald-400" />
Copied
</>
) : ( ) : (
<> <><Copy className="w-3 h-3" />Copy</>
<Copy className="w-3 h-3" />
Copy
</>
)} )}
</button> </button>
</div> </div>
<pre className="px-5 py-4 text-sm text-emerald-300 font-mono overflow-x-auto whitespace-pre-wrap break-all leading-relaxed"> <pre className="px-5 py-4 text-sm text-emerald-600 dark:text-emerald-300 font-mono overflow-x-auto whitespace-pre-wrap break-all leading-relaxed bg-[var(--bg-subtle)]">
{result.installCommand} {result.installCommand}
</pre> </pre>
<div className="px-5 py-3 border-t border-zinc-800 text-xs text-zinc-600"> <div className="px-5 py-3 border-t border-[var(--border)] text-xs text-[var(--text-faint)]">
Paste this command on your server (requires root). It will Paste on your server (requires root) installs cloudflared, registers the tunnel, and configures SSH CA trust.
install cloudflared, register the tunnel, configure SSH CA
trust, and create the login user.
</div> </div>
</div> </div>
)} )}
@ -290,12 +308,10 @@ export default function CreatePage() {
href={result.launchUrl} href={result.launchUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-emerald-300 hover:bg-emerald-500/10 transition" className="flex items-center justify-center gap-2 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-dim)] p-4 text-emerald-600 dark:text-emerald-300 hover:bg-[var(--accent-dim)] transition font-medium"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
<span className="font-medium"> Open {result.launchUrl}
Open {result.launchUrl}
</span>
</a> </a>
)} )}
</div> </div>
@ -321,6 +337,10 @@ export default function CreatePage() {
.form-input::placeholder { .form-input::placeholder {
color: var(--text-faint); color: var(--text-faint);
} }
select.form-input option {
background: var(--bg-card);
color: var(--text);
}
`}</style> `}</style>
</div> </div>
); );
@ -339,12 +359,12 @@ function Field({
}) { }) {
return ( return (
<div> <div>
<label className="flex items-center gap-1.5 text-sm font-medium text-zinc-300 mb-1.5"> <label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
<span className="text-zinc-500">{icon}</span> <span className="text-[var(--text-muted)]">{icon}</span>
{label} {label}
</label> </label>
{children} {children}
{hint && <p className="text-xs text-zinc-600 mt-1">{hint}</p>} {hint && <p className="text-xs text-[var(--text-muted)] mt-1">{hint}</p>}
</div> </div>
); );
} }

View File

@ -208,6 +208,16 @@ export async function getSshAppById(id: string): Promise<SshApp | null> {
return ssh.find((a) => a.id === id) ?? null; return ssh.find((a) => a.id === id) ?? null;
} }
// ─── Zones ───────────────────────────────────────────────────────────────────
export async function listZones(): Promise<{ id: string; name: string }[]> {
const json = await cfGet("/zones?per_page=50&status=active", 300);
return ((json.result ?? []) as { id: string; name: string }[]).map((z) => ({
id: z.id,
name: z.name,
}));
}
// ─── Tunnels ───────────────────────────────────────────────────────────────── // ─── Tunnels ─────────────────────────────────────────────────────────────────
export async function listTunnels(): Promise<Tunnel[]> { export async function listTunnels(): Promise<Tunnel[]> {