auth: single-step super-admin bootstrap (no verification email)
Collapse the two-phase bootstrap (email a verification link → verify screen) into one direct step: the Bootstrap form collects name/email/username/password and posts once to /bootstrap, which creates the first user directly as an active Super Admin (+ provisions DATA_PATH/<email>). Still gated to an empty user table. The invite flow (/verify, /verify-token) is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,37 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { useForm } from 'hooks/useForm';
|
import { useForm } from 'hooks/useForm';
|
||||||
|
|
||||||
export function Bootstrap() {
|
export function Bootstrap() {
|
||||||
const authClient = useClient('/api/auth');
|
const authClient = useClient('/api/auth');
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const { formRef, state, isValid } = useForm(
|
||||||
const { formRef, state, isValid } = useForm({}, (state) => !!state.email);
|
{},
|
||||||
|
(s) => !!s.email && !!s.name && !!s.username && !!s.password && s.password === s.confirmPassword,
|
||||||
|
);
|
||||||
|
|
||||||
const handleBootstrap = async () => {
|
const handleBootstrap = async (ev: React.FormEvent) => {
|
||||||
const trimmed = state.email.trim();
|
ev.preventDefault();
|
||||||
if (!trimmed || isSubmitting) return;
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await authClient.post('/bootstrap', { email: trimmed });
|
await authClient.post('/bootstrap', {
|
||||||
setDone(true);
|
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) {
|
} catch (ex) {
|
||||||
const error = ex as { message?: string };
|
const error = ex as { message?: string };
|
||||||
toast.error(error.message || 'Bootstrap failed. Please try again.');
|
toast.error(error.message || 'Bootstrap failed. Please try again.');
|
||||||
@@ -29,36 +41,80 @@ export function Bootstrap() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="md:min-w-[480px]">
|
<Card className="md:min-w-[480px] flex flex-col gap-6">
|
||||||
{done ? (
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<div className="text-duck-dark text-2xl font-bold">Welcome, Admin</div>
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
<div className="text-duck-dark/60">Create your administrator account</div>
|
||||||
<div className="text-sm text-duck-dark/60">
|
</div>
|
||||||
<p>A verification link has been sent to <strong>{state.email}</strong>.</p>
|
|
||||||
<p>Click it to activate your account.</p>
|
<form ref={formRef} onSubmit={handleBootstrap} className="grid gap-4">
|
||||||
</div>
|
<Label className="grid gap-2">
|
||||||
</div>
|
<span className="text-duck-dark/70">Name</span>
|
||||||
) : (
|
<Input
|
||||||
<>
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Welcome, Admin</h2>
|
type="text"
|
||||||
<p className="text-sm text-duck-dark/60 mb-6">Enter your email to create your administrator account.</p>
|
name="name"
|
||||||
<form ref={formRef} className="flex gap-2">
|
placeholder="Your name"
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Email</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label className="grid gap-2">
|
||||||
|
<span className="text-duck-dark/70">Username</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="your-username"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="grid md:flex gap-4">
|
||||||
|
<Label className="grid gap-2 flex-1">
|
||||||
|
<span className="text-duck-dark/70">Password</span>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
name="email"
|
type="password"
|
||||||
placeholder="admin@example.com"
|
name="password"
|
||||||
className="flex-1 px-4 py-2.5 rounded-lg border-2 border-duck-dark/20 text-sm text-duck-dark focus:outline-none focus:border-duck-teal/50"
|
placeholder="Min 12 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<Button
|
</Label>
|
||||||
onClick={handleBootstrap}
|
|
||||||
disabled={!isValid || isSubmitting}
|
<Label className="grid gap-2 flex-1">
|
||||||
className="bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-6 py-2.5 rounded-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
<span className="text-duck-dark/70">Confirm Password</span>
|
||||||
>
|
<Input
|
||||||
{isSubmitting ? 'Sending...' : 'Bootstrap'}
|
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||||
</Button>
|
type="password"
|
||||||
</form>
|
name="confirmPassword"
|
||||||
</>
|
placeholder="Confirm your password"
|
||||||
)}
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,28 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { sendMail } from 'emailer';
|
|
||||||
import { getUserCount, createUser } from 'officerdb';
|
import { getUserCount, createUser } from 'officerdb';
|
||||||
import { sign, verify } from '@@/jwt';
|
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
import { validateUsername } from './validate-username';
|
import { validateUsername } from './validate-username';
|
||||||
import { provisionUserEnvironment } from '../users/provision';
|
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) {
|
export const bootstrapHandler: Handler = async function (ctx) {
|
||||||
const body = ctx.get('body');
|
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();
|
const userCount = await getUserCount();
|
||||||
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
|
||||||
|
|
||||||
if (!token) {
|
const email = body.email as string;
|
||||||
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 name = body.name as string;
|
const name = body.name as string;
|
||||||
const username = body.username as string;
|
const username = body.username as string;
|
||||||
const password = body.password as string;
|
const password = body.password as string;
|
||||||
const confirmPassword = body.confirmPassword 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');
|
if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required');
|
||||||
const validUsername = validateUsername(username);
|
const validUsername = validateUsername(username);
|
||||||
validatePassword(password);
|
validatePassword(password);
|
||||||
@@ -51,7 +31,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|
||||||
const user = await createUser({
|
const user = await createUser({
|
||||||
email: payload.email,
|
email,
|
||||||
password: passwordHash,
|
password: passwordHash,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
username: validUsername,
|
username: validUsername,
|
||||||
@@ -60,8 +40,8 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Provision the super admin's environment (DATA_PATH/<email> + configs) on creation. This is the only
|
// Provision the super admin's environment (DATA_PATH/<email> + configs) on creation. This is the only
|
||||||
// account-creation flow for a single-user platform, and — unlike the invite/verify flow — nothing
|
// account-creation flow for a single-user platform, and nothing else provisions the first user.
|
||||||
// else runs provisioning for the first user. Fire-and-forget, mirroring verifyHandler.
|
// Fire-and-forget, mirroring verifyHandler.
|
||||||
provisionUserEnvironment(user.email, user.username ?? validUsername).catch((err) => {
|
provisionUserEnvironment(user.email, user.username ?? validUsername).catch((err) => {
|
||||||
console.error('[bootstrap] failed to provision super admin environment:', err);
|
console.error('[bootstrap] failed to provision super admin environment:', err);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user