feat(frontend): Implement complete authentication system
Implemented comprehensive JWT-based authentication with token refresh mechanism, user state management, and protected routes. Changes: - Upgraded API client from fetch to Axios with automatic token refresh interceptors - Created API configuration with centralized endpoint definitions - Implemented Zustand auth store for user state management with persistence - Created React Query hooks for login, register, logout, and current user - Built login and registration pages with form validation (Zod + React Hook Form) - Implemented AuthGuard component for route protection - Enhanced Header with user dropdown menu and logout functionality - Updated Sidebar with user information display at bottom - Added Team navigation item to sidebar - Configured environment variables for API base URL Technical Details: - JWT token storage in localStorage with secure key names - Automatic token refresh on 401 responses - Request queueing during token refresh to prevent race conditions - TypeScript strict typing throughout - ESLint compliant code (fixed type safety issues) - Proper error handling with user-friendly messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
106
app/(auth)/login/page.tsx
Normal file
106
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import Link from 'next/link';
|
||||
import { useLogin } from '@/lib/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const registered = searchParams.get('registered');
|
||||
|
||||
const { mutate: login, isPending, error } = useLogin();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: LoginForm) => {
|
||||
login(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold">ColaFlow</h1>
|
||||
<p className="mt-2 text-gray-600">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mt-8 space-y-6 rounded-lg bg-white p-8 shadow"
|
||||
>
|
||||
{registered && (
|
||||
<div className="rounded bg-green-50 p-3 text-sm text-green-600">
|
||||
Registration successful! Please sign in.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-red-50 p-3 text-sm text-red-600">
|
||||
{(error as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message || 'Login failed. Please try again.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="mt-1"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="mt-1"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link href="/register" className="text-blue-600 hover:underline">
|
||||
Don't have an account? Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
app/(auth)/register/page.tsx
Normal file
142
app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import Link from 'next/link';
|
||||
import { useRegisterTenant } from '@/lib/hooks/useAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
'Password must contain uppercase, lowercase, and number'
|
||||
),
|
||||
fullName: z.string().min(2, 'Full name must be at least 2 characters'),
|
||||
tenantName: z
|
||||
.string()
|
||||
.min(2, 'Organization name must be at least 2 characters'),
|
||||
});
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { mutate: registerTenant, isPending, error } = useRegisterTenant();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: RegisterForm) => {
|
||||
registerTenant(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold">ColaFlow</h1>
|
||||
<p className="mt-2 text-gray-600">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mt-8 space-y-6 rounded-lg bg-white p-8 shadow"
|
||||
>
|
||||
{error && (
|
||||
<div className="rounded bg-red-50 p-3 text-sm text-red-600">
|
||||
{(error as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message ||
|
||||
'Registration failed. Please try again.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fullName">Full Name</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
{...register('fullName')}
|
||||
className="mt-1"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.fullName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="mt-1"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="mt-1"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tenantName">Organization Name</Label>
|
||||
<Input
|
||||
id="tenantName"
|
||||
type="text"
|
||||
{...register('tenantName')}
|
||||
className="mt-1"
|
||||
placeholder="Acme Inc."
|
||||
/>
|
||||
{errors.tenantName && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{errors.tenantName.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link href="/login" className="text-blue-600 hover:underline">
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { AuthGuard } from '@/components/providers/AuthGuard';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -12,18 +13,20 @@ export default function DashboardLayout({
|
||||
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 transition-all duration-200 ${
|
||||
sidebarOpen ? 'ml-64' : 'ml-0'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
<AuthGuard>
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 transition-all duration-200 ${
|
||||
sidebarOpen ? 'ml-64' : 'ml-0'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user