feat(unit-2-auth): Implement authentication pages and guards
Completes Unit 2 Code Generation (Steps 1-21 of 24): - SetupPage: First Owner account creation with language preference - InviteCompletePage: User invitation completion with token validation - InitGuard: System initialization status checking - RoleGuard: Role-based access control for protected routes - API hooks: useSetup, useValidateInvitation, useCompleteInvitation - Shared password validation schema via Zod - Error display components: FormErrorBanner, FieldError - MSW mock handlers for setup and invitation flows - i18n translations (en/nl) for auth screens - Router updates: public routes (/setup, /invite/complete) + role guards - Unit/integration tests: 25/29 passing (86% pass rate) - Build: ✅ PASSED, Lint: ✅ PASSED, Tests: 86% PASSED Stories: US-01, US-02, US-03, US-04, US-05, US-06, US-07, US-13, US-14 Remaining: Step 22 (README), Step 23 (final verification), Step 24 (commit) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,74 +1,83 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { InvitationValidation, InviteCompleteRequest } from '@/api/types';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { api } from '../lib/api-client';
|
||||
|
||||
interface ValidateState {
|
||||
data: InvitationValidation | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
interface InvitationValidationResponse {
|
||||
valid: boolean;
|
||||
email?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useValidateInvitation(token: string | undefined): ValidateState {
|
||||
const [state, setState] = useState<ValidateState>({ data: null, isLoading: token !== undefined, error: null });
|
||||
const mounted = useRef(true);
|
||||
interface InvitationCompleteRequest {
|
||||
token: string;
|
||||
name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface InvitationCompleteResponse {
|
||||
message: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useValidateInvitation(token: string | null) {
|
||||
const [data, setData] = useState<InvitationValidationResponse | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (!token) return;
|
||||
|
||||
const fetchValidation = async () => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `/Invitation/validate?token=${encodeURIComponent(token)}`;
|
||||
const response = await api.get(url);
|
||||
setData(response as InvitationValidationResponse);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
setData(null);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (token === undefined) {
|
||||
setState({ data: null, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ data: null, isLoading: true, error: null });
|
||||
|
||||
api.get<InvitationValidation>(`/Invitation/validate?token=${encodeURIComponent(token)}`)
|
||||
.then((data) => {
|
||||
if (mounted.current) setState({ data, isLoading: false, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (mounted.current)
|
||||
setState({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
});
|
||||
fetchValidation();
|
||||
}, [token]);
|
||||
|
||||
return state;
|
||||
return {
|
||||
data,
|
||||
isPending,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
interface CompleteSetupState {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
export function useCompleteInvitation() {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
interface UseCompleteSetup extends CompleteSetupState {
|
||||
mutate: (data: InviteCompleteRequest) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useCompleteSetup(): UseCompleteSetup {
|
||||
const [state, setState] = useState<CompleteSetupState>({ isLoading: false, error: null });
|
||||
|
||||
const mutate = useCallback(async (data: InviteCompleteRequest): Promise<void> => {
|
||||
setState({ isLoading: true, error: null });
|
||||
const mutateAsync = useCallback(async (data: InvitationCompleteRequest): Promise<InvitationCompleteResponse> => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post('/Invitation/complete', data);
|
||||
setState({ isLoading: false, error: null });
|
||||
const response = await api.post('/Invitation/complete', data);
|
||||
return response as InvitationCompleteResponse;
|
||||
} catch (err: unknown) {
|
||||
setState({ isLoading: false, error: err instanceof Error ? err : new Error(String(err)) });
|
||||
throw err;
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => setState({ isLoading: false, error: null }), []);
|
||||
|
||||
return { ...state, mutate, reset };
|
||||
return {
|
||||
mutateAsync,
|
||||
isPending,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user