10 KiB
10 KiB
TASK-FE-002: Authentication & Authorization UI
ID: TASK-FE-002 Title: Login, Session Management & RBAC UI Category: Foundation Priority: P0 (Critical) Effort: 3-4 days Dependencies: TASK-FE-001, TASK-BE-002 Assigned To: Frontend Developer
📋 Overview
Implement authentication UI including login form, session management with Zustand, and permission-based UI rendering.
🎯 Objectives
- Create login page with form validation
- Implement JWT token management
- Setup Zustand auth store
- Create protected route middleware
- Implement permission-based UI components
- Add logout functionality
✅ Acceptance Criteria
- User can login with username/password
- JWT token stored securely
- Unauthorized users redirected to login
- UI elements hidden based on permissions
- Session persists after page reload
- Logout clears session
🔧 Implementation Steps
Step 1: Create Auth Store (Zustand)
// File: src/lib/stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
roles: Array<{
role_name: string;
scope: string;
scope_id: number;
}>;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
logout: () => void;
hasPermission: (permission: string, scope?: string) => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) => {
set({ user, token, isAuthenticated: true });
localStorage.setItem('auth-token', token);
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false });
localStorage.removeItem('auth-token');
},
hasPermission: (permission, scope) => {
const { user } = get();
if (!user) return false;
// Check user roles for permission
return user.roles.some((role) => {
// Permission logic based on RBAC
return true; // Implement actual logic
});
},
}),
{
name: 'auth-storage',
}
)
);
Step 2: Login API Client
// File: src/lib/api/auth.ts
import { apiClient } from './client';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
user: {
user_id: number;
username: string;
email: string;
first_name: string;
last_name: string;
roles: any[];
};
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post('/auth/login', credentials);
return response.data;
},
logout: async (): Promise<void> => {
await apiClient.post('/auth/logout');
},
getCurrentUser: async () => {
const response = await apiClient.get('/auth/me');
return response.data;
},
};
Step 3: Login Page
// File: src/app/(public)/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { authApi } from '@/lib/api/auth';
import { useAuthStore } from '@/lib/stores/auth-store';
const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const [error, setError] = useState('');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
setError('');
const response = await authApi.login(data);
setAuth(response.user, response.access_token);
router.push('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl">LCBP3-DMS Login</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded">
{error}
</div>
)}
<div>
<Label htmlFor="username">Username</Label>
<Input
id="username"
{...register('username')}
placeholder="Enter username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">
{errors.username.message}
</p>
)}
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...register('password')}
placeholder="Enter password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
Step 4: Protected Route Middleware
// File: src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token');
const isPublicPage = request.nextUrl.pathname.startsWith('/login');
if (!token && !isPublicPage) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (token && isPublicPage) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Step 5: Permission-Based UI Components
// File: src/components/common/can.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import { ReactNode } from 'react';
interface CanProps {
permission: string;
scope?: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Can({
permission,
scope,
children,
fallback = null,
}: CanProps) {
const hasPermission = useAuthStore((state) => state.hasPermission);
if (!hasPermission(permission, scope)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Usage example
import { Can } from '@/components/common/can';
<Can permission="correspondence:create">
<Button>Create Correspondence</Button>
</Can>;
Step 6: User Menu Component
// File: src/components/layout/user-menu.tsx
'use client';
import { useAuthStore } from '@/lib/stores/auth-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useRouter } from 'next/navigation';
export function UserMenu() {
const router = useRouter();
const { user, logout } = useAuthStore();
if (!user) return null;
const handleLogout = () => {
logout();
router.push('/login');
};
const initials = `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">
{user.first_name} {user.last_name}
</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
🧪 Testing & Verification
Test Cases
-
Login Success
- Enter valid credentials
- User redirected to dashboard
- Token stored
-
Login Failure
- Enter invalid credentials
- Error message displayed
- User stays on login page
-
Protected Routes
- Access protected route without login → Redirect to login
- Login → Access protected route successfully
-
Session Persistence
- Login → Refresh page → Still logged in
-
Logout
- Click logout → Token cleared → Redirected to login
-
Permission-Based UI
- User with permission sees button
- User without permission doesn't see button
📦 Deliverables
- Login page with validation
- Zustand auth store
- Auth API client
- Protected route middleware
- Permission-based UI components
- User menu with logout
- Session persistence
🔗 Related Documents
Created: 2025-12-01 Status: Ready