- Add Role property to User entity with migration - Create BootstrapController for first admin user creation - Remove [AllowAnonymous] from all learning content controllers - Create AdminController with admin-only endpoints - Create AdminService for user management - Create UserReportService for progress reports - Add UserRepository implementation - Update AuthService with role support feat(frontend): Implement authentication system - Add AuthStore with React context for auth state management - Create Login, Register, Landing, and Home pages - Add ProtectedRoute and AdminRoute components - Create Auth API types and client - Configure Vite with @/ path alias - Add comprehensive CSS styles for auth and landing pages BREAKING CHANGE: All learning content now requires authentication. Users must register and sign in before accessing lessons, quizzes, and stories. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
256 lines
6.7 KiB
TypeScript
256 lines
6.7 KiB
TypeScript
/**
|
|
* Auth store for managing authentication state
|
|
* Uses React context for state management
|
|
*/
|
|
|
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import type { CurrentUser } from '@/types/api/auth';
|
|
import * as authApi from '@/lib/api/auth';
|
|
|
|
// Token storage keys
|
|
const TOKEN_KEY = 'deutschlernen_token';
|
|
const REFRESH_TOKEN_KEY = 'deutschlernen_refresh_token';
|
|
const TOKEN_EXPIRY_KEY = 'deutschlernen_token_expiry';
|
|
|
|
interface AuthState {
|
|
token: string | null;
|
|
refreshToken: string | null;
|
|
user: CurrentUser | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
interface AuthContextType extends AuthState {
|
|
login: (email: string, password: string) => Promise<void>;
|
|
register: (username: string, email: string, password: string) => Promise<void>;
|
|
logout: () => void;
|
|
refreshAuthToken: () => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
|
|
// Helper to check if token is expired
|
|
function isTokenExpired(expiry: string | null): boolean {
|
|
if (!expiry) return true;
|
|
const expiryDate = new Date(expiry);
|
|
// Add 30 second buffer
|
|
return expiryDate.getTime() - 30000 < Date.now();
|
|
}
|
|
|
|
// Helper to get token from storage
|
|
function getTokenFromStorage(): { token: string | null; refreshToken: string | null; expiry: string | null } {
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
return { token, refreshToken, expiry };
|
|
}
|
|
|
|
// Helper to store token
|
|
function storeToken(token: string, refreshToken: string, expiry: string): void {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiry);
|
|
}
|
|
|
|
// Helper to clear token
|
|
function clearToken(): void {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
|
}
|
|
|
|
// Provider component
|
|
interface AuthProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function AuthProvider({ children }: AuthProviderProps) {
|
|
const [state, setState] = useState<AuthState>({
|
|
token: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: true,
|
|
error: null,
|
|
});
|
|
|
|
// Initialize from storage
|
|
useEffect(() => {
|
|
const initializeAuth = async () => {
|
|
const { token, refreshToken, expiry } = getTokenFromStorage();
|
|
|
|
if (!token || isTokenExpired(expiry)) {
|
|
clearToken();
|
|
setState({
|
|
token: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Try to get current user
|
|
const user = await authApi.getCurrentUser(token);
|
|
setState({
|
|
token,
|
|
refreshToken,
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
} catch (err) {
|
|
clearToken();
|
|
setState({
|
|
token: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
}
|
|
};
|
|
|
|
initializeAuth();
|
|
}, []);
|
|
|
|
// Login function
|
|
const login = useCallback(async (email: string, password: string) => {
|
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
|
|
|
try {
|
|
const response = await authApi.login({ email, password });
|
|
storeToken(response.token, response.refreshToken, response.expiresAt);
|
|
|
|
// Get current user info
|
|
const user = await authApi.getCurrentUser(response.token);
|
|
|
|
setState({
|
|
token: response.token,
|
|
refreshToken: response.refreshToken,
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
} catch (err) {
|
|
setState(prev => ({
|
|
...prev,
|
|
isLoading: false,
|
|
error: err instanceof Error ? err.message : 'Login failed',
|
|
}));
|
|
throw err;
|
|
}
|
|
}, []);
|
|
|
|
// Register function
|
|
const register = useCallback(async (username: string, email: string, password: string) => {
|
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
|
|
|
try {
|
|
const response = await authApi.register({ username, email, password });
|
|
storeToken(response.token, response.refreshToken, response.expiresAt);
|
|
|
|
// Get current user info
|
|
const user = await authApi.getCurrentUser(response.token);
|
|
|
|
setState({
|
|
token: response.token,
|
|
refreshToken: response.refreshToken,
|
|
user,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
} catch (err) {
|
|
setState(prev => ({
|
|
...prev,
|
|
isLoading: false,
|
|
error: err instanceof Error ? err.message : 'Registration failed',
|
|
}));
|
|
throw err;
|
|
}
|
|
}, []);
|
|
|
|
// Logout function
|
|
const logout = useCallback(() => {
|
|
clearToken();
|
|
setState({
|
|
token: null,
|
|
refreshToken: null,
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
}, []);
|
|
|
|
// Refresh token function
|
|
const refreshAuthToken = useCallback(async () => {
|
|
const { refreshToken } = getTokenFromStorage();
|
|
|
|
if (!refreshToken) {
|
|
logout();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await authApi.refreshToken({ refreshToken });
|
|
storeToken(response.token, response.refreshToken, response.expiresAt);
|
|
|
|
// Update state with new token
|
|
setState(prev => ({
|
|
...prev,
|
|
token: response.token,
|
|
refreshToken: response.refreshToken,
|
|
}));
|
|
} catch (err) {
|
|
logout();
|
|
}
|
|
}, [logout]);
|
|
|
|
// Clear error function
|
|
const clearError = useCallback(() => {
|
|
setState(prev => ({ ...prev, error: null }));
|
|
}, []);
|
|
|
|
const value: AuthContextType = {
|
|
...state,
|
|
login,
|
|
register,
|
|
logout,
|
|
refreshAuthToken,
|
|
clearError,
|
|
};
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
// Hook to use auth context
|
|
export function useAuth(): AuthContextType {
|
|
const context = useContext(AuthContext);
|
|
if (context === undefined) {
|
|
throw new Error('useAuth must be used within an AuthProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// Helper hook to check if user is admin
|
|
export function useIsAdmin(): boolean {
|
|
const { user } = useAuth();
|
|
return user?.role === 'Admin';
|
|
}
|
|
|
|
// Helper to get auth header
|
|
export function getAuthHeader(token: string | null): Record<string, string> {
|
|
if (!token) return {};
|
|
return { Authorization: `Bearer ${token}` };
|
|
}
|