feat: first-run setup wizard on initial admin login

After first login, admins are redirected to a 4-step setup wizard:
1. Server hostname (FQDN)
2. Primary and secondary nameservers
3. Admin email and optional password change
4. Review and confirm

Setup state is persisted in the settings table (setup_complete=true).
Once complete, the redirect never triggers again. Settings are saved
to the DB and hostname is applied via hostnamectl.
This commit is contained in:
NixReaper
2026-04-05 13:17:16 -05:00
parent 75394fcaa5
commit fa61d0d766
5 changed files with 414 additions and 14 deletions
+2
View File
@@ -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' })
+83
View File
@@ -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' } })
})
}