diff --git a/nixserver/src/App.tsx b/nixserver/src/App.tsx index c3940a9..6c31cd9 100644 --- a/nixserver/src/App.tsx +++ b/nixserver/src/App.tsx @@ -7,37 +7,56 @@ import Dashboard from './pages/Dashboard' import Accounts from './pages/Accounts' import CreateAccount from './pages/CreateAccount' import Packages from './pages/Packages' +import Setup from './pages/Setup' // Lazy-loaded pages (built incrementally) -const Resellers = React.lazy(() => import('./pages/Resellers')) -const DNS = React.lazy(() => import('./pages/DNS')) -const Email = React.lazy(() => import('./pages/Email')) -const SSL = React.lazy(() => import('./pages/SSL')) -const PHP = React.lazy(() => import('./pages/PHP')) -const Services = React.lazy(() => import('./pages/Services')) -const Backup = React.lazy(() => import('./pages/Backup')) -const Security = React.lazy(() => import('./pages/Security')) -const System = React.lazy(() => import('./pages/System')) -const WebServer = React.lazy(() => import('./pages/WebServer')) -const Settings = React.lazy(() => import('./pages/Settings')) +const Resellers = React.lazy(() => import('./pages/Resellers')) +const DNS = React.lazy(() => import('./pages/DNS')) +const Email = React.lazy(() => import('./pages/Email')) +const SSL = React.lazy(() => import('./pages/SSL')) +const PHP = React.lazy(() => import('./pages/PHP')) +const Services = React.lazy(() => import('./pages/Services')) +const Backup = React.lazy(() => import('./pages/Backup')) +const Security = React.lazy(() => import('./pages/Security')) +const System = React.lazy(() => import('./pages/System')) +const WebServer = React.lazy(() => import('./pages/WebServer')) +const Settings = React.lazy(() => import('./pages/Settings')) function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { user, loading } = useAuth() + const { user, loading, setupComplete } = useAuth() + if (loading) return (
) + if (!user) return + + // Admin must complete first-run setup before accessing the panel + if (user.role === 'admin' && !setupComplete) { + return + } + return <>{children} } function AppRoutes() { - const { user } = useAuth() + const { user, setupComplete } = useAuth() return ( : } /> + + {/* First-run setup — only accessible to authenticated admins who haven't completed setup */} + + : setupComplete + ? + : + } /> + @@ -81,6 +100,7 @@ function AppRoutes() { }> } /> + } /> ) diff --git a/nixserver/src/contexts/AuthContext.tsx b/nixserver/src/contexts/AuthContext.tsx index 5065961..944bc83 100644 --- a/nixserver/src/contexts/AuthContext.tsx +++ b/nixserver/src/contexts/AuthContext.tsx @@ -11,6 +11,8 @@ interface User { interface AuthContextValue { user: User | null loading: boolean + setupComplete: boolean + markSetupComplete: () => void login: (username: string, password: string) => Promise logout: () => Promise } @@ -20,6 +22,7 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) + const [setupComplete, setSetupComplete] = useState(true) const fetchMe = useCallback(async () => { const token = localStorage.getItem('access_token') @@ -32,6 +35,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return } setUser(data.data) + + // Only admins trigger the setup check + if (data.data.role === 'admin') { + const { data: setupData } = await api.get('/nixserver/setup/status') + setSetupComplete(setupData.data.setupComplete) + } } catch { localStorage.clear() } finally { @@ -49,6 +58,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('access_token', data.data.accessToken) localStorage.setItem('refresh_token', data.data.refreshToken) setUser(data.data.user) + + // Check setup status after login + if (data.data.role === 'admin') { + try { + const { data: setupData } = await api.get('/nixserver/setup/status') + setSetupComplete(setupData.data.setupComplete) + } catch { + setSetupComplete(true) // fail open — don't block login on setup check failure + } + } } const logout = async () => { @@ -56,10 +75,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { await api.post('/auth/logout', { refreshToken }) } catch {} localStorage.clear() setUser(null) + setSetupComplete(true) } + const markSetupComplete = () => setSetupComplete(true) + return ( - + {children} ) diff --git a/nixserver/src/pages/Setup.tsx b/nixserver/src/pages/Setup.tsx new file mode 100644 index 0000000..27e0311 --- /dev/null +++ b/nixserver/src/pages/Setup.tsx @@ -0,0 +1,273 @@ +import React, { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Server, Globe, ShieldCheck, CheckCircle, ChevronRight, ChevronLeft } from 'lucide-react' +import { api } from '../api/client' +import { useAuth } from '../contexts/AuthContext' + +const STEPS = [ + { id: 1, label: 'Server', icon: Server }, + { id: 2, label: 'Nameservers', icon: Globe }, + { id: 3, label: 'Admin', icon: ShieldCheck }, + { id: 4, label: 'Finish', icon: CheckCircle }, +] + +interface FormData { + hostname: string + nameserver1: string + nameserver2: string + adminEmail: string + adminPassword: string + confirmPassword: string +} + +export default function Setup() { + const { user, markSetupComplete } = useAuth() + const navigate = useNavigate() + + const [step, setStep] = useState(1) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + + const [form, setForm] = useState({ + hostname: '', + nameserver1: '', + nameserver2: '', + adminEmail: user?.email ?? '', + adminPassword: '', + confirmPassword: '', + }) + + const set = (key: keyof FormData) => (e: React.ChangeEvent) => + setForm(prev => ({ ...prev, [key]: e.target.value })) + + // ── Validation per step ────────────────────────────────────────────────────── + function validate(): string { + if (step === 1) { + if (!form.hostname.trim()) return 'Hostname is required' + if (!/^[a-zA-Z0-9.-]+$/.test(form.hostname)) return 'Invalid hostname format' + } + if (step === 2) { + if (!form.nameserver1.trim()) return 'Primary nameserver is required' + if (!form.nameserver2.trim()) return 'Secondary nameserver is required' + } + if (step === 3) { + if (!form.adminEmail.trim()) return 'Email is required' + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) return 'Invalid email address' + if (form.adminPassword && form.adminPassword.length < 8) return 'Password must be at least 8 characters' + if (form.adminPassword !== form.confirmPassword) return 'Passwords do not match' + } + return '' + } + + function next() { + const err = validate() + if (err) { setError(err); return } + setError('') + setStep(s => s + 1) + } + + function back() { + setError('') + setStep(s => s - 1) + } + + async function finish() { + setSaving(true) + setError('') + try { + await api.post('/nixserver/setup/complete', { + hostname: form.hostname, + nameserver1: form.nameserver1, + nameserver2: form.nameserver2, + adminEmail: form.adminEmail, + adminPassword: form.adminPassword || undefined, + confirmPassword: form.confirmPassword || undefined, + }) + markSetupComplete() + navigate('/', { replace: true }) + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error + setError(msg ?? 'Failed to save setup — please try again') + } finally { + setSaving(false) + } + } + + // ── Step content ───────────────────────────────────────────────────────────── + function StepContent() { + if (step === 1) return ( + <> +

Server Identity

+

Set the fully-qualified hostname for this server.

+ +

+ Example: server.yourdomain.com +

+ + ) + + if (step === 2) return ( + <> +

Nameservers

+

These nameservers will be used for hosted domains.

+ +
+ +
+ + ) + + if (step === 3) return ( + <> +

Admin Account

+

Update your admin email and optionally set a new password.

+ +
+ +
+
+ +
+ + ) + + // Step 4 — Review + return ( + <> +

Review & Finish

+

Confirm your settings before applying them.

+
+ + + + + +
+ + ) + } + + // ── Render ─────────────────────────────────────────────────────────────────── + return ( +
+
+ + {/* Header */} +
+
+ +
+

NixServer Setup

+

Configure your server before getting started

+
+ + {/* Step indicators */} +
+ {STEPS.map((s, i) => { + const Icon = s.icon + const active = step === s.id + const complete = step > s.id + return ( + +
+
+ +
+ + {s.label} + +
+ {i < STEPS.length - 1 && ( +
s.id ? 'bg-indigo-600' : 'bg-[#2a2d3e]'}`} /> + )} + + ) + })} +
+ + {/* Card */} +
+ + + {error && ( +
+ {error} +
+ )} + + {/* Navigation */} +
+ + + {step < 4 ? ( + + ) : ( + + )} +
+
+ +

+ NixServer v0.3.1 · You can revisit these settings anytime under Settings +

+
+
+ ) +} + +// ── Small helpers ────────────────────────────────────────────────────────────── + +function Field({ + label, value, onChange, placeholder, type = 'text', autoFocus, +}: { + label: string + value: string + onChange: (e: React.ChangeEvent) => void + placeholder?: string + type?: string + autoFocus?: boolean +}) { + return ( +
+ + +
+ ) +} + +function ReviewRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/server/src/routes/nixserver/index.ts b/server/src/routes/nixserver/index.ts index 48f067e..229fb86 100644 --- a/server/src/routes/nixserver/index.ts +++ b/server/src/routes/nixserver/index.ts @@ -11,8 +11,10 @@ import systemRoutes from './system.js' import securityRoutes from './security.js' import backupRoutes from './backup.js' import webserverRoutes from './webserver.js' +import setupRoutes from './setup.js' export default async function nixserverRoutes(fastify: FastifyInstance) { + fastify.register(setupRoutes, { prefix: '/setup' }) fastify.register(accountRoutes, { prefix: '/accounts' }) fastify.register(packageRoutes, { prefix: '/packages' }) fastify.register(resellerRoutes, { prefix: '/resellers' }) diff --git a/server/src/routes/nixserver/setup.ts b/server/src/routes/nixserver/setup.ts new file mode 100644 index 0000000..5cd9a53 --- /dev/null +++ b/server/src/routes/nixserver/setup.ts @@ -0,0 +1,83 @@ +import type { FastifyInstance } from 'fastify' +import { z } from 'zod' +import bcrypt from 'bcryptjs' +import prisma from '../../db/client.js' +import { requireAdmin } from '../../middleware/auth.js' +import { exec } from '../../core/exec.js' + +const SetupSchema = z.object({ + hostname: z.string().min(1).max(253), + nameserver1: z.string().min(1).max(253), + nameserver2: z.string().min(1).max(253), + adminEmail: z.string().email(), + adminPassword: z.string().min(8).optional(), + confirmPassword: z.string().optional(), +}).refine(data => { + if (data.adminPassword && data.adminPassword !== data.confirmPassword) return false + return true +}, { message: 'Passwords do not match', path: ['confirmPassword'] }) + +export default async function setupRoutes(fastify: FastifyInstance) { + // GET /api/nixserver/setup/status — check if first-run setup has been completed + fastify.get('/status', { preHandler: [requireAdmin] }, async (_request, reply) => { + const setting = await prisma.setting.findUnique({ where: { key: 'setup_complete' } }) + return reply.send({ success: true, data: { setupComplete: setting?.value === 'true' } }) + }) + + // POST /api/nixserver/setup/complete — save setup values and mark as done + fastify.post('/complete', { preHandler: [requireAdmin] }, async (request, reply) => { + const body = SetupSchema.safeParse(request.body) + if (!body.success) { + return reply.code(400).send({ success: false, error: body.error.issues[0]?.message }) + } + + const { hostname, nameserver1, nameserver2, adminEmail, adminPassword } = body.data + const user = request.user! + + // Persist settings + const settings = [ + { key: 'hostname', value: hostname }, + { key: 'nameserver1', value: nameserver1 }, + { key: 'nameserver2', value: nameserver2 }, + { key: 'setup_complete', value: 'true' }, + ] + + for (const { key, value } of settings) { + await prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }) + } + + // Update admin email + await prisma.adminUser.update({ + where: { id: user.sub }, + data: { email: adminEmail }, + }) + + // Update admin password if provided + if (adminPassword) { + const hash = await bcrypt.hash(adminPassword, 12) + await prisma.adminUser.update({ + where: { id: user.sub }, + data: { passwordHash: hash }, + }) + } + + // Apply hostname to the system (non-fatal if it fails) + await exec('hostnamectl', ['set-hostname', hostname]).catch(() => {}) + + // Log the action + await prisma.auditLog.create({ + data: { + adminId: user.sub, + action: 'setup.complete', + target: 'system', + details: `First-run setup completed. Hostname: ${hostname}`, + }, + }).catch(() => {}) + + return reply.send({ success: true, data: { message: 'Setup complete' } }) + }) +}