) =>
+ 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 (
+
+
+ {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' } })
+ })
+}