Compare commits
2 Commits
f29d9669f8
...
0af53c9b55
| Author | SHA1 | Date | |
|---|---|---|---|
|
0af53c9b55
|
|||
|
3900dff5a1
|
@@ -590,7 +590,7 @@ func (s *Store) templateRoles(ctx context.Context, templateID int64) ([]Template
|
||||
return nil, fmt.Errorf("get template roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var roles []TemplateRole
|
||||
roles := make([]TemplateRole, 0)
|
||||
for rows.Next() {
|
||||
var r TemplateRole
|
||||
if err := rows.Scan(&r.ID, &r.TemplateID, &r.RoleName, &r.Count); err != nil {
|
||||
@@ -608,7 +608,7 @@ func (s *Store) templateVolunteerIDs(ctx context.Context, templateID int64) ([]i
|
||||
return nil, fmt.Errorf("get template volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
ids := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
@@ -631,7 +631,7 @@ func (s *Store) instanceVolunteers(ctx context.Context, instanceID int64) ([]Ins
|
||||
return nil, fmt.Errorf("get instance volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var vols []InstanceVolunteer
|
||||
vols := make([]InstanceVolunteer, 0)
|
||||
for rows.Next() {
|
||||
var iv InstanceVolunteer
|
||||
var confirmedAt sql.NullString
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.unsupervised.ca/walkies/internal/notification"
|
||||
"git.unsupervised.ca/walkies/internal/schedule"
|
||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||
"git.unsupervised.ca/walkies/internal/setup"
|
||||
"git.unsupervised.ca/walkies/internal/timeoff"
|
||||
"git.unsupervised.ca/walkies/internal/volunteer"
|
||||
)
|
||||
@@ -34,6 +35,9 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
checkinStore := checkin.NewStore(db)
|
||||
checkinHandler := checkin.NewHandler(checkinStore)
|
||||
|
||||
setupStore := setup.NewStore(db)
|
||||
setupHandler := setup.NewHandler(setupStore, authSvc)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
@@ -45,6 +49,10 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
r.Post("/auth/login", volunteerHandler.Login)
|
||||
r.Post("/auth/activate", volunteerHandler.Activate)
|
||||
|
||||
// Public setup endpoints (self-disabling once first user exists)
|
||||
r.Get("/setup/status", setupHandler.Status)
|
||||
r.Post("/setup/admin", setupHandler.CreateAdmin)
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Authenticate(authSvc))
|
||||
|
||||
84
internal/setup/handler.go
Normal file
84
internal/setup/handler.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/auth"
|
||||
"git.unsupervised.ca/walkies/internal/respond"
|
||||
)
|
||||
|
||||
// TokenIssuer is the subset of auth.Service the setup handler needs.
|
||||
type TokenIssuer interface {
|
||||
IssueToken(volunteerID int64, role string) (string, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
store Storer
|
||||
authSvc TokenIssuer
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
|
||||
return &Handler{store: store, authSvc: authSvc}
|
||||
}
|
||||
|
||||
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
||||
func NewHandlerFromInterfaces(store Storer, authSvc TokenIssuer) *Handler {
|
||||
return &Handler{store: store, authSvc: authSvc}
|
||||
}
|
||||
|
||||
// Status handles GET /api/v1/setup/status.
|
||||
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := h.store.NeedsSetup(r.Context())
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not check setup status")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
|
||||
}
|
||||
|
||||
// CreateAdmin handles POST /api/v1/setup/admin.
|
||||
func (h *Handler) CreateAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Name == "" || body.Email == "" || body.Password == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 8 {
|
||||
respond.Error(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not hash password")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.store.CreateAdmin(r.Context(), body.Name, body.Email, hashed)
|
||||
if errors.Is(err, ErrSetupAlreadyDone) {
|
||||
respond.Error(w, http.StatusForbidden, "setup already completed")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not create admin account")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.authSvc.IssueToken(id, "admin")
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusCreated, map[string]string{"token": token})
|
||||
}
|
||||
168
internal/setup/handler_test.go
Normal file
168
internal/setup/handler_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package setup_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/setup"
|
||||
)
|
||||
|
||||
// ---- fakes ---------------------------------------------------------------
|
||||
|
||||
type fakeStore struct {
|
||||
needsSetup bool
|
||||
needsSetupErr error
|
||||
createAdminID int64
|
||||
createAdminErr error
|
||||
}
|
||||
|
||||
func (f *fakeStore) NeedsSetup(_ context.Context) (bool, error) {
|
||||
return f.needsSetup, f.needsSetupErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAdmin(_ context.Context, _, _, _ string) (int64, error) {
|
||||
return f.createAdminID, f.createAdminErr
|
||||
}
|
||||
|
||||
type fakeTokenIssuer struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTokenIssuer) IssueToken(_ int64, _ string) (string, error) {
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var _ setup.Storer = (*fakeStore)(nil)
|
||||
var _ setup.TokenIssuer = (*fakeTokenIssuer)(nil)
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
func do(t *testing.T, handler http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var b *bytes.Reader
|
||||
if body != "" {
|
||||
b = bytes.NewReader([]byte(body))
|
||||
} else {
|
||||
b = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, b)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// ---- Status tests --------------------------------------------------------
|
||||
|
||||
func TestStatus_NeedsSetup(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{needsSetup: true},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.Status, "GET", "/api/v1/setup/status", "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
var resp map[string]bool
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp["needs_setup"] {
|
||||
t.Error("expected needs_setup=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatus_SetupDone(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{needsSetup: false},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.Status, "GET", "/api/v1/setup/status", "")
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
var resp map[string]bool
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["needs_setup"] {
|
||||
t.Error("expected needs_setup=false")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreateAdmin tests ---------------------------------------------------
|
||||
|
||||
func TestCreateAdmin_Success(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{createAdminID: 1},
|
||||
&fakeTokenIssuer{token: "jwt-token"},
|
||||
)
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
|
||||
`{"name":"Admin","email":"admin@example.com","password":"supersecret"}`)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["token"] != "jwt-token" {
|
||||
t.Errorf("expected token jwt-token, got %q", resp["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_AlreadyDone(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{createAdminErr: setup.ErrSetupAlreadyDone},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
|
||||
`{"name":"Admin","email":"admin@example.com","password":"supersecret"}`)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_MissingFields(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing name", `{"email":"a@b.com","password":"supersecret"}`},
|
||||
{"missing email", `{"name":"Admin","password":"supersecret"}`},
|
||||
{"missing password", `{"name":"Admin","email":"a@b.com"}`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_PasswordTooShort(t *testing.T) {
|
||||
h := setup.NewHandlerFromInterfaces(
|
||||
&fakeStore{},
|
||||
&fakeTokenIssuer{},
|
||||
)
|
||||
w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin",
|
||||
`{"name":"Admin","email":"admin@example.com","password":"short"}`)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
68
internal/setup/setup.go
Normal file
68
internal/setup/setup.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrSetupAlreadyDone = errors.New("setup already completed")
|
||||
|
||||
// Storer is the interface for setup-related DB operations.
|
||||
type Storer interface {
|
||||
NeedsSetup(ctx context.Context) (bool, error)
|
||||
CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error)
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// NeedsSetup returns true when the volunteers table has zero rows.
|
||||
func (s *Store) NeedsSetup(ctx context.Context) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
// CreateAdmin atomically checks that no users exist and inserts the first admin.
|
||||
func (s *Store) CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if count > 0 {
|
||||
return 0, ErrSetupAlreadyDone
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO volunteers (name, email, password, role, active, operational_roles) VALUES (?, ?, ?, 'admin', 1, '')`,
|
||||
name, email, hashedPassword,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
import { api } from './api';
|
||||
|
||||
// Mock all API calls so the app renders without a backend
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
getSetupStatus: jest.fn(),
|
||||
createSetupAdmin: jest.fn(),
|
||||
listVolunteers: jest.fn().mockResolvedValue([]),
|
||||
listSchedules: jest.fn().mockResolvedValue([]),
|
||||
listTimeOff: jest.fn().mockResolvedValue([]),
|
||||
@@ -14,16 +17,27 @@ jest.mock('./api', () => ({
|
||||
OPERATIONAL_ROLES: [],
|
||||
}));
|
||||
|
||||
test('renders login page when unauthenticated', () => {
|
||||
const mockGetSetupStatus = api.getSetupStatus as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
render(<App />);
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
|
||||
mockGetSetupStatus.mockResolvedValue({ needs_setup: false });
|
||||
});
|
||||
|
||||
test('login page has email and password fields', () => {
|
||||
localStorage.clear();
|
||||
test('renders login page when unauthenticated and setup done', async () => {
|
||||
render(<App />);
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('login page has email and password fields', async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('redirects to setup when needs_setup is true', async () => {
|
||||
mockGetSetupStatus.mockResolvedValue({ needs_setup: true });
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('heading', { name: /initial setup/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { api } from './api';
|
||||
import Login from './pages/Login';
|
||||
import Activate from './pages/Activate';
|
||||
import Setup from './pages/Setup';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Schedules from './pages/Schedules';
|
||||
import TimeOff from './pages/TimeOff';
|
||||
@@ -50,15 +52,57 @@ function LoginRoute() {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
// Setup context lets the Setup page flip needsSetup after creating the admin.
|
||||
const SetupContext = createContext<{ setNeedsSetup: (v: boolean) => void }>({
|
||||
setNeedsSetup: () => {},
|
||||
});
|
||||
export const useSetup = () => useContext(SetupContext);
|
||||
|
||||
function SetupGate({ children }: { children: ReactNode }) {
|
||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSetupStatus()
|
||||
.then(r => setNeedsSetup(r.needs_setup))
|
||||
.catch(() => setNeedsSetup(false));
|
||||
}, []);
|
||||
|
||||
if (needsSetup === null) return null;
|
||||
|
||||
if (needsSetup) {
|
||||
return (
|
||||
<SetupContext.Provider value={{ setNeedsSetup }}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
</SetupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupContext.Provider value={{ setNeedsSetup }}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginRoute />} />
|
||||
<Route path="/activate" element={<Activate />} />
|
||||
<Route path="/*" element={<ProtectedLayout />} />
|
||||
</Routes>
|
||||
</SetupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<SetupGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRoute />} />
|
||||
<Route path="/activate" element={<Activate />} />
|
||||
<Route path="/*" element={<ProtectedLayout />} />
|
||||
</Routes>
|
||||
</SetupGate>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Setup
|
||||
getSetupStatus: () =>
|
||||
request<{ needs_setup: boolean }>('GET', '/setup/status'),
|
||||
createSetupAdmin: (data: { name: string; email: string; password: string }) =>
|
||||
request<{ token: string }>('POST', '/setup/admin', data),
|
||||
|
||||
// Auth
|
||||
login: (email: string, password: string) =>
|
||||
request<{ token: string }>('POST', '/auth/login', { email, password }),
|
||||
|
||||
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