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:
Yaojia Wang
2025-11-04 09:09:09 +01:00
parent 797b1f6eed
commit e60b70de52
12 changed files with 798 additions and 165 deletions

106
app/(auth)/login/page.tsx Normal file
View 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&apos;t have an account? Sign up
</Link>
</div>
</form>
</div>
</div>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,11 +1,23 @@
'use client';
import { Menu } from 'lucide-react';
import { Menu, Bell, LogOut, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/ui-store';
import { useLogout } from '@/lib/hooks/useAuth';
import { useAuthStore } from '@/stores/authStore';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function Header() {
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const { mutate: logout } = useLogout();
const user = useAuthStore((state) => state.user);
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -25,7 +37,36 @@ export function Header() {
</div>
<div className="ml-auto flex items-center gap-4">
{/* Add user menu, notifications, etc. here */}
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
<span className="sr-only">Notifications</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<User className="h-5 w-5" />
<span className="sr-only">User menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user?.fullName}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>

View File

@@ -2,9 +2,10 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, FolderKanban, Settings } from 'lucide-react';
import { LayoutDashboard, FolderKanban, Settings, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { useAuthStore } from '@/stores/authStore';
const navItems = [
{
@@ -17,6 +18,11 @@ const navItems = [
href: '/projects',
icon: FolderKanban,
},
{
title: 'Team',
href: '/team',
icon: Users,
},
{
title: 'Settings',
href: '/settings',
@@ -27,33 +33,55 @@ const navItems = [
export function Sidebar() {
const pathname = usePathname();
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
const user = useAuthStore((state) => state.user);
if (!sidebarOpen) return null;
return (
<aside className="fixed left-0 top-14 z-40 h-[calc(100vh-3.5rem)] w-64 border-r border-border bg-background">
<nav className="flex flex-col gap-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
<aside className="fixed left-0 top-14 z-40 flex h-[calc(100vh-3.5rem)] w-64 flex-col border-r border-border bg-background">
<div className="flex-1 overflow-y-auto">
<nav className="flex flex-col gap-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
);
})}
</nav>
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
);
})}
</nav>
</div>
{/* User info section at bottom */}
<div className="border-t border-border p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
{user?.fullName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium">{user?.fullName}</p>
<p className="truncate text-xs text-muted-foreground">
{user?.tenantName}
</p>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Role: {user?.role}
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore';
import { useCurrentUser } from '@/lib/hooks/useAuth';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuthStore();
const { isLoading: isUserLoading } = useCurrentUser();
useEffect(() => {
if (!isLoading && !isUserLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, isUserLoading, router]);
if (isLoading || isUserLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}

View File

@@ -1,124 +1,135 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api/v1';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { API_BASE_URL } from './config';
// Log API URL for debugging
if (typeof window !== 'undefined') {
console.log('[API Client] API_URL:', API_URL);
console.log('[API Client] NEXT_PUBLIC_API_URL:', process.env.NEXT_PUBLIC_API_URL);
}
// Create axios instance
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public data?: any
) {
super(message);
this.name = 'ApiError';
}
}
// Token management
const TOKEN_KEY = 'colaflow_access_token';
const REFRESH_TOKEN_KEY = 'colaflow_refresh_token';
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new ApiError(
response.status,
errorData.message || response.statusText,
errorData
);
console.error('[API Client] Request failed:', {
url: response.url,
status: response.status,
statusText: response.statusText,
errorData,
});
throw error;
}
export const tokenManager = {
getAccessToken: () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
},
if (response.status === 204) {
return {} as T;
}
setAccessToken: (token: string) => {
if (typeof window === 'undefined') return;
localStorage.setItem(TOKEN_KEY, token);
},
return response.json();
}
getRefreshToken: () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
},
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`;
setRefreshToken: (token: string) => {
if (typeof window === 'undefined') return;
localStorage.setItem(REFRESH_TOKEN_KEY, token);
},
console.log('[API Client] Request:', {
method: options.method || 'GET',
url,
endpoint,
clearTokens: () => {
if (typeof window === 'undefined') return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
},
};
// Request interceptor: automatically add Access Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = tokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: automatically refresh Token
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Add auth token if available
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
// Merge with options headers
if (options.headers) {
Object.assign(headers, options.headers);
}
const config: RequestInit = {
...options,
headers,
};
try {
const response = await fetch(url, config);
const result = await handleResponse<T>(response);
console.log('[API Client] Response:', {
url,
status: response.status,
data: result,
});
return result;
} catch (error) {
console.error('[API Client] Network error:', {
url,
error: error instanceof Error ? error.message : String(error),
errorObject: error,
});
throw error;
}
}
export const api = {
get: <T>(endpoint: string, options?: RequestInit) =>
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
post: <T>(endpoint: string, data?: any, options?: RequestInit) =>
apiRequest<T>(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(data),
}),
put: <T>(endpoint: string, data?: any, options?: RequestInit) =>
apiRequest<T>(endpoint, {
...options,
method: 'PUT',
body: JSON.stringify(data),
}),
patch: <T>(endpoint: string, data?: any, options?: RequestInit) =>
apiRequest<T>(endpoint, {
...options,
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: <T>(endpoint: string, options?: RequestInit) =>
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// If 401 and not a refresh token request, try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = tokenManager.getRefreshToken();
if (!refreshToken) {
tokenManager.clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(error);
}
try {
const { data } = await axios.post(`${API_BASE_URL}/api/auth/refresh`, {
refreshToken,
});
tokenManager.setAccessToken(data.accessToken);
tokenManager.setRefreshToken(data.refreshToken);
apiClient.defaults.headers.common.Authorization = `Bearer ${data.accessToken}`;
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
processQueue(null, data.accessToken);
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
tokenManager.clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);

23
lib/api/config.ts Normal file
View File

@@ -0,0 +1,23 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
export const API_ENDPOINTS = {
// Auth
LOGIN: '/api/auth/login',
REGISTER_TENANT: '/api/auth/register-tenant',
REFRESH_TOKEN: '/api/auth/refresh',
LOGOUT: '/api/auth/logout',
ME: '/api/auth/me',
// Users
USERS: '/api/users',
USER_PROFILE: (userId: string) => `/api/users/${userId}`,
// Tenants
TENANT_USERS: (tenantId: string) => `/api/tenants/${tenantId}/users`,
ASSIGN_ROLE: (tenantId: string, userId: string) =>
`/api/tenants/${tenantId}/users/${userId}/role`,
// Projects (to be implemented)
PROJECTS: '/api/projects',
PROJECT: (id: string) => `/api/projects/${id}`,
};

110
lib/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,110 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, tokenManager } from '../api/client';
import { API_ENDPOINTS } from '../api/config';
import { useAuthStore } from '@/stores/authStore';
import { useRouter } from 'next/navigation';
interface LoginCredentials {
email: string;
password: string;
}
interface RegisterTenantData {
email: string;
password: string;
fullName: string;
tenantName: string;
}
export function useLogin() {
const setUser = useAuthStore((state) => state.setUser);
const router = useRouter();
return useMutation({
mutationFn: async (credentials: LoginCredentials) => {
const { data } = await apiClient.post(API_ENDPOINTS.LOGIN, credentials);
return data;
},
onSuccess: (data) => {
tokenManager.setAccessToken(data.accessToken);
tokenManager.setRefreshToken(data.refreshToken);
setUser({
id: data.user.id,
email: data.user.email,
fullName: data.user.fullName,
tenantId: data.user.tenantId,
tenantName: data.user.tenantName,
role: data.user.role,
isEmailVerified: data.user.isEmailVerified,
});
router.push('/dashboard');
},
});
}
export function useRegisterTenant() {
const router = useRouter();
return useMutation({
mutationFn: async (data: RegisterTenantData) => {
const response = await apiClient.post(
API_ENDPOINTS.REGISTER_TENANT,
data
);
return response.data;
},
onSuccess: () => {
router.push('/login?registered=true');
},
});
}
export function useLogout() {
const clearUser = useAuthStore((state) => state.clearUser);
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: async () => {
try {
await apiClient.post(API_ENDPOINTS.LOGOUT);
} catch {
// Ignore logout errors
}
},
onSuccess: () => {
tokenManager.clearTokens();
clearUser();
queryClient.clear();
router.push('/login');
},
});
}
export function useCurrentUser() {
const setUser = useAuthStore((state) => state.setUser);
const clearUser = useAuthStore((state) => state.clearUser);
const setLoading = useAuthStore((state) => state.setLoading);
return useQuery({
queryKey: ['currentUser'],
queryFn: async () => {
const { data } = await apiClient.get(API_ENDPOINTS.ME);
setUser(data);
setLoading(false);
return data;
},
enabled: !!tokenManager.getAccessToken(),
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
throwOnError: () => {
clearUser();
tokenManager.clearTokens();
setLoading(false);
return false;
},
});
}

116
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2",
"axios": "^1.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.552.0",
@@ -3087,6 +3088,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3113,6 +3120,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz",
"integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3221,7 +3239,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3342,6 +3359,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3500,6 +3529,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3533,7 +3571,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -3645,7 +3682,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3655,7 +3691,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3693,7 +3728,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3706,7 +3740,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4321,6 +4354,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4337,11 +4390,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -4402,7 +4470,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4436,7 +4503,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4524,7 +4590,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4603,7 +4668,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4616,7 +4680,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4632,7 +4695,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5624,7 +5686,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5654,6 +5715,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -6218,6 +6300,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2",
"axios": "^1.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.552.0",

45
stores/authStore.ts Normal file
View File

@@ -0,0 +1,45 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface User {
id: string;
email: string;
fullName: string;
tenantId: string;
tenantName: string;
role: 'TenantOwner' | 'TenantAdmin' | 'TenantMember' | 'TenantGuest';
isEmailVerified: boolean;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
setUser: (user: User) => void;
clearUser: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
setUser: (user) =>
set({ user, isAuthenticated: true, isLoading: false }),
clearUser: () =>
set({ user: null, isAuthenticated: false, isLoading: false }),
setLoading: (loading) => set({ isLoading: loading }),
}),
{
name: 'colaflow-auth',
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
);