diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx index 82848527..fe7f3594 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx @@ -1,25 +1,37 @@ import { useState } from 'react'; import { toast } from 'sonner'; +import { useNavigate } from 'react-router'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Card } from '@/components/Card'; import { useForm } from 'hooks/useForm'; export function Bootstrap() { const authClient = useClient('/api/auth'); + const navigate = useNavigate(); const [isSubmitting, setIsSubmitting] = useState(false); - const [done, setDone] = useState(false); - const { formRef, state, isValid } = useForm({}, (state) => !!state.email); + const { formRef, state, isValid } = useForm( + {}, + (s) => !!s.email && !!s.name && !!s.username && !!s.password && s.password === s.confirmPassword, + ); - const handleBootstrap = async () => { - const trimmed = state.email.trim(); - if (!trimmed || isSubmitting) return; + const handleBootstrap = async (ev: React.FormEvent) => { + ev.preventDefault(); + if (!isValid || isSubmitting) return; setIsSubmitting(true); try { - await authClient.post('/bootstrap', { email: trimmed }); - setDone(true); + await authClient.post('/bootstrap', { + email: state.email.trim(), + name: state.name.trim(), + username: state.username.trim(), + password: state.password, + confirmPassword: state.confirmPassword, + }); + toast.success('Administrator account created. Please sign in.'); + navigate('/'); } catch (ex) { const error = ex as { message?: string }; toast.error(error.message || 'Bootstrap failed. Please try again.'); @@ -29,36 +41,80 @@ export function Bootstrap() { }; return ( - - {done ? ( -
-

Check your email

-
-

A verification link has been sent to {state.email}.

-

Click it to activate your account.

-
-
- ) : ( - <> -

Welcome, Admin

-

Enter your email to create your administrator account.

-
+ +
+
Welcome, Admin
+
Create your administrator account
+
+ + + + + + + + +
+ + + +
+ +
+ +
+
); } diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index 50c1aae2..8bd8e5a8 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -1,48 +1,28 @@ import type { Handler } from 'hono'; -import { sendMail } from 'emailer'; import { getUserCount, createUser } from 'officerdb'; -import { sign, verify } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePassword } from './validate-password'; import { validateUsername } from './validate-username'; import { provisionUserEnvironment } from '../users/provision'; +// Single-step super-admin bootstrap: the first user is created directly as an active Super Admin, with +// no email-verification round-trip. Gated to an empty user table (registration is otherwise closed). export const bootstrapHandler: Handler = async function (ctx) { const body = ctx.get('body'); - const origin = ctx.get('origin'); - const token = body.token as string; - const email = body.email as string; const userCount = await getUserCount(); if (userCount > 0) throw errors.FORBIDDEN('Registration is closed'); - if (!token) { - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - throw errors.BAD_REQUEST('Invalid email address'); - } - - const verificationCode = await sign({ email }, '24h'); - const url = `${origin}/auth/verify?verificationCode=${verificationCode}`; - - await sendMail({ - template: 'VerifyAdmin', - subject: 'Verify your officer.dev account', - to: email, - data: { name: email, url }, - }); - - return ctx.json({ ok: true }); - } - - const payload = ((await verify(token).catch(() => null)) as { email: string } | null); - if (!payload?.email) throw errors.BAD_REQUEST('Invalid or expired token'); - + const email = body.email as string; const name = body.name as string; const username = body.username as string; const password = body.password as string; const confirmPassword = body.confirmPassword as string; + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw errors.BAD_REQUEST('Invalid email address'); + } if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required'); const validUsername = validateUsername(username); validatePassword(password); @@ -51,7 +31,7 @@ export const bootstrapHandler: Handler = async function (ctx) { const passwordHash = await argon2.hash(password); const user = await createUser({ - email: payload.email, + email, password: passwordHash, name: name.trim(), username: validUsername, @@ -60,8 +40,8 @@ export const bootstrapHandler: Handler = async function (ctx) { }); // Provision the super admin's environment (DATA_PATH/ + configs) on creation. This is the only - // account-creation flow for a single-user platform, and — unlike the invite/verify flow — nothing - // else runs provisioning for the first user. Fire-and-forget, mirroring verifyHandler. + // account-creation flow for a single-user platform, and nothing else provisions the first user. + // Fire-and-forget, mirroring verifyHandler. provisionUserEnvironment(user.email, user.username ?? validUsername).catch((err) => { console.error('[bootstrap] failed to provision super admin environment:', err); });