Implement Issue #11: Initial admin account setup on first deploy
When the database has no users, the UI redirects to /setup and prompts for creation of the first admin account. The setup endpoints are self-disabling — once any user exists, POST /setup/admin returns 403 and the frontend redirects /setup back to /login. The backend uses an atomic transaction to prevent race conditions on concurrent requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
104
web/src/pages/Setup.test.tsx
Normal file
104
web/src/pages/Setup.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import Setup from './Setup';
|
||||
import { api } from '../api';
|
||||
import { AuthProvider } from '../auth';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
api: {
|
||||
getSetupStatus: jest.fn(),
|
||||
createSetupAdmin: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useSetup from App to provide setNeedsSetup
|
||||
const mockSetNeedsSetup = jest.fn();
|
||||
jest.mock('../App', () => ({
|
||||
useSetup: () => ({ setNeedsSetup: mockSetNeedsSetup }),
|
||||
}));
|
||||
|
||||
const mockCreateSetupAdmin = api.createSetupAdmin as jest.Mock;
|
||||
|
||||
function renderSetup() {
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={['/setup']}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/" element={<div>Dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateSetupAdmin.mockReset();
|
||||
mockSetNeedsSetup.mockReset();
|
||||
});
|
||||
|
||||
test('renders all form fields', () => {
|
||||
renderSetup();
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /create admin account/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows error when passwords do not match', async () => {
|
||||
renderSetup();
|
||||
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
|
||||
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows error when password too short', async () => {
|
||||
renderSetup();
|
||||
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'short' } });
|
||||
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'short' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
|
||||
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls api.createSetupAdmin and navigates on success', async () => {
|
||||
mockCreateSetupAdmin.mockResolvedValueOnce({ token: 'jwt-token' });
|
||||
renderSetup();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } });
|
||||
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSetupAdmin).toHaveBeenCalledWith({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'goodpassword',
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetNeedsSetup).toHaveBeenCalledWith(false);
|
||||
});
|
||||
expect(await screen.findByText(/dashboard/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows error when API returns failure', async () => {
|
||||
mockCreateSetupAdmin.mockRejectedValueOnce(new Error('setup already completed'));
|
||||
renderSetup();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } });
|
||||
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
|
||||
|
||||
expect(await screen.findByText(/setup already completed/i)).toBeInTheDocument();
|
||||
});
|
||||
77
web/src/pages/Setup.tsx
Normal file
77
web/src/pages/Setup.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../auth';
|
||||
import { useSetup } from '../App';
|
||||
|
||||
export default function Setup() {
|
||||
const { login } = useAuth();
|
||||
const { setNeedsSetup } = useSetup();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!name || !email || !password) {
|
||||
setError('All fields are required');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { token } = await api.createSetupAdmin({ name, email, password });
|
||||
login(token);
|
||||
setNeedsSetup(false);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Walkies</h1>
|
||||
<h2>Initial Setup</h2>
|
||||
<p>Create the first admin account to get started.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<label>
|
||||
Name
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Confirm Password
|
||||
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
|
||||
</label>
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating...' : 'Create Admin Account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user