diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..03bad35 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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; + +export default function LoginPage() { + const searchParams = useSearchParams(); + const registered = searchParams.get('registered'); + + const { mutate: login, isPending, error } = useLogin(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = (data: LoginForm) => { + login(data); + }; + + return ( +
+
+
+

ColaFlow

+

Sign in to your account

+
+ +
+ {registered && ( +
+ Registration successful! Please sign in. +
+ )} + + {error && ( +
+ {(error as { response?: { data?: { message?: string } } }) + ?.response?.data?.message || 'Login failed. Please try again.'} +
+ )} + +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ + + +
+ + Don't have an account? Sign up + +
+
+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..1060557 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -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; + +export default function RegisterPage() { + const { mutate: registerTenant, isPending, error } = useRegisterTenant(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const onSubmit = (data: RegisterForm) => { + registerTenant(data); + }; + + return ( +
+
+
+

ColaFlow

+

Create your account

+
+ +
+ {error && ( +
+ {(error as { response?: { data?: { message?: string } } }) + ?.response?.data?.message || + 'Registration failed. Please try again.'} +
+ )} + +
+ + + {errors.fullName && ( +

+ {errors.fullName.message} +

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +

+ Must contain uppercase, lowercase, and number +

+
+ +
+ + + {errors.tenantName && ( +

+ {errors.tenantName.message} +

+ )} +
+ + + +
+ + Already have an account? Sign in + +
+
+
+
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 84dc138..984805b 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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 ( -
-
-
- -
-
{children}
-
+ +
+
+
+ +
+
{children}
+
+
-
+ ); } diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 17bf53f..b3d15de 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -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 (
@@ -25,7 +37,36 @@ export function Header() {
- {/* Add user menu, notifications, etc. here */} + + + + + + + + +
+

+ {user?.fullName} +

+

+ {user?.email} +

+
+
+ + logout()}> + + Log out + +
+
diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index c1e455e..b470e87 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -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 ( - ); } diff --git a/components/providers/AuthGuard.tsx b/components/providers/AuthGuard.tsx new file mode 100644 index 0000000..34d7fa9 --- /dev/null +++ b/components/providers/AuthGuard.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return <>{children}; +} diff --git a/lib/api/client.ts b/lib/api/client.ts index 662f6fb..78dd82d 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -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(response: Response): Promise { - 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( - endpoint: string, - options: RequestInit = {} -): Promise { - 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 = { - '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(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: (endpoint: string, options?: RequestInit) => - apiRequest(endpoint, { ...options, method: 'GET' }), - - post: (endpoint: string, data?: any, options?: RequestInit) => - apiRequest(endpoint, { - ...options, - method: 'POST', - body: JSON.stringify(data), - }), - - put: (endpoint: string, data?: any, options?: RequestInit) => - apiRequest(endpoint, { - ...options, - method: 'PUT', - body: JSON.stringify(data), - }), - - patch: (endpoint: string, data?: any, options?: RequestInit) => - apiRequest(endpoint, { - ...options, - method: 'PATCH', - body: JSON.stringify(data), - }), - - delete: (endpoint: string, options?: RequestInit) => - apiRequest(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); + } +); diff --git a/lib/api/config.ts b/lib/api/config.ts new file mode 100644 index 0000000..8153115 --- /dev/null +++ b/lib/api/config.ts @@ -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}`, +}; diff --git a/lib/hooks/useAuth.ts b/lib/hooks/useAuth.ts new file mode 100644 index 0000000..c151ac5 --- /dev/null +++ b/lib/hooks/useAuth.ts @@ -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; + }, + }); +} diff --git a/package-lock.json b/package-lock.json index df1fa11..30057ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f4fb277..1530518 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/stores/authStore.ts b/stores/authStore.ts new file mode 100644 index 0000000..1faed6a --- /dev/null +++ b/stores/authStore.ts @@ -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()( + 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, + }), + } + ) +);