feat: implement auth flow and app initialization
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GuestGuard } from '@/guards';
|
||||
import { useLoginForm } from '@/hooks/useLoginForm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
@@ -11,93 +12,95 @@ export default function LoginPage() {
|
||||
const { register, handleSubmit, errors, isLoading } = useLoginForm();
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
|
||||
<Image
|
||||
src="/background.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="55vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-background/35" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-10">
|
||||
<div className="max-w-lg space-y-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
|
||||
VisionRoad
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
|
||||
Road intelligence for faster inspection decisions
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Review projects, uploads, detections, and chainage insights from one secure
|
||||
workspace.
|
||||
</p>
|
||||
<GuestGuard>
|
||||
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
|
||||
<Image
|
||||
src="/background.png"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="55vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-background/35" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-10">
|
||||
<div className="max-w-lg space-y-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
|
||||
VisionRoad
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
|
||||
Road intelligence for faster inspection decisions
|
||||
</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Review projects, uploads, detections, and chainage insights from one secure
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-screen items-center justify-center px-6 py-10">
|
||||
<div className="w-full max-w-sm space-y-7">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use your email and password to continue.
|
||||
</p>
|
||||
</header>
|
||||
<section className="flex min-h-screen items-center justify-center px-6 py-10">
|
||||
<div className="w-full max-w-sm space-y-7">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use your email and password to continue.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@visionroad.ai"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!errors.email}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@visionroad.ai"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!errors.email}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!errors.password}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
aria-invalid={!!errors.password}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Signing in
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Signing in
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ModeToggle } from '@/components/mode-toogle';
|
||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AuthGuard } from '@/guards';
|
||||
import React from 'react';
|
||||
|
||||
const ModulesLayout = ({
|
||||
@@ -11,23 +12,25 @@ const ModulesLayout = ({
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return (
|
||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
||||
<AppSidebar className="bg-transparent" />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Centering container wrapper */}
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
<AuthGuard>
|
||||
<SidebarProvider className="bg-transparent overflow-hidden">
|
||||
<AppSidebar className="bg-transparent" />
|
||||
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Centering container wrapper */}
|
||||
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
|
||||
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
|
||||
<SidebarTrigger />
|
||||
<ModeToggle />
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<BreadcrumbBasic />
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
</div>
|
||||
<div className="p-6 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</AuthGuard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import NextAuth from 'next-auth';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider';
|
||||
import AuthProvider from '@/providers/AuthProvider';
|
||||
import AppInitializer from '@/providers/AppInitializer';
|
||||
import QueryProvider from '@/providers/QueryProvider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { BackgroundGradient } from '@/components/background-gradient';
|
||||
@@ -37,13 +37,13 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<AppInitializer>
|
||||
<div className="relative min-h-screen isolate">
|
||||
<BackgroundGradient />
|
||||
{children}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</AuthProvider>
|
||||
</AppInitializer>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { LayoutDashboard, Plus, Layers, Package, MapPin, Milestone } from 'lucide-react';
|
||||
import { LayoutDashboard, Plus, Layers, Package, Milestone } from 'lucide-react';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import {
|
||||
@@ -17,14 +17,10 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
|
||||
// This is the navigation data
|
||||
const data = {
|
||||
user: {
|
||||
name: 'Vision Admin',
|
||||
email: 'admin@visionroad.ai',
|
||||
avatar: '/avatars/profile.jpg',
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
@@ -56,6 +52,12 @@ const data = {
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname();
|
||||
const authUser = useAppStore((state) => state.user);
|
||||
const user = {
|
||||
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
|
||||
email: authUser?.email || '',
|
||||
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -87,7 +89,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={data.user} />
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
@@ -29,6 +35,27 @@ export function NavUser({
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
const clearAuth = useAuthStore((state) => state.logout);
|
||||
const clearApp = useAppStore((state) => state.clear);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut) return;
|
||||
|
||||
setIsLoggingOut(true);
|
||||
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// Local logout should still complete if the server session is already invalid.
|
||||
} finally {
|
||||
clearAuth();
|
||||
clearApp();
|
||||
router.replace(ROUTES.LOGIN);
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -91,9 +118,15 @@ export function NavUser({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
handleLogout();
|
||||
}}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
<LogOut />
|
||||
{isLoggingOut ? 'Logging out' : 'Log out'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
30
src/guards/AuthGuard.tsx
Normal file
30
src/guards/AuthGuard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||
const user = useAppStore((state) => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
if (!accessToken) {
|
||||
console.debug('[auth] guard:redirect-login');
|
||||
router.replace(ROUTES.LOGIN);
|
||||
}
|
||||
}, [accessToken, isInitialized, router]);
|
||||
|
||||
if (!isInitialized || !accessToken || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
26
src/guards/GuestGuard.tsx
Normal file
26
src/guards/GuestGuard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function GuestGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isInitialized = useAppStore((state) => state.isInitialized);
|
||||
const user = useAppStore((state) => state.user);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && accessToken && user) {
|
||||
console.debug('[auth] guest-guard:redirect-dashboard');
|
||||
router.replace(ROUTES.DASHBOARD);
|
||||
}
|
||||
}, [accessToken, isInitialized, router, user]);
|
||||
|
||||
if (!isInitialized || (accessToken && user)) return null;
|
||||
|
||||
return children;
|
||||
}
|
||||
29
src/guards/PermissionGuard.tsx
Normal file
29
src/guards/PermissionGuard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type PermissionGuardProps = {
|
||||
children: ReactNode;
|
||||
permissions: string[];
|
||||
mode?: 'all' | 'any';
|
||||
fallback?: ReactNode;
|
||||
};
|
||||
|
||||
export function PermissionGuard({
|
||||
children,
|
||||
permissions,
|
||||
mode = 'all',
|
||||
fallback = null,
|
||||
}: PermissionGuardProps) {
|
||||
const grantedPermissions = useAppStore((state) => state.permissions);
|
||||
const normalizedPermissions = grantedPermissions.map((permission) => permission.toLowerCase());
|
||||
const hasPermission =
|
||||
mode === 'all'
|
||||
? permissions.every((permission) => normalizedPermissions.includes(permission.toLowerCase()))
|
||||
: permissions.some((permission) => normalizedPermissions.includes(permission.toLowerCase()));
|
||||
|
||||
if (!hasPermission) return fallback;
|
||||
|
||||
return children;
|
||||
}
|
||||
3
src/guards/index.ts
Normal file
3
src/guards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './AuthGuard';
|
||||
export * from './GuestGuard';
|
||||
export * from './PermissionGuard';
|
||||
@@ -1,14 +1,17 @@
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import type { LoginPayload, UserProfile } from '@/types';
|
||||
import type { LoginPayload } from '@/types';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { getSession, signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useLoginForm = () => {
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||
const setLoading = useAppStore((state) => state.setLoading);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -23,32 +26,26 @@ export const useLoginForm = () => {
|
||||
|
||||
const onSubmit = async (data: LoginPayload) => {
|
||||
try {
|
||||
const callbackUrl =
|
||||
new URLSearchParams(window.location.search).get('callbackUrl') ?? ROUTES.DASHBOARD;
|
||||
const result = await signIn('credentials', {
|
||||
console.debug('[auth] login:start');
|
||||
const loginResponse = await authService.login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
callbackUrl,
|
||||
});
|
||||
const accessToken = loginResponse.access_token;
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(result.error);
|
||||
if (!accessToken) {
|
||||
toast.error('Login failed: no access token returned');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
if (session?.user) {
|
||||
setAuth(
|
||||
session.user as UserProfile,
|
||||
session.accessToken ?? null,
|
||||
session.refreshToken ?? null,
|
||||
);
|
||||
}
|
||||
setAccessToken(accessToken);
|
||||
setLoading();
|
||||
console.debug('[auth] login:token-stored');
|
||||
await initializeAuthenticatedApp();
|
||||
console.debug('[auth] login:initialized, redirecting', ROUTES.DASHBOARD);
|
||||
|
||||
toast.success('Login successful');
|
||||
router.push(result?.url ?? callbackUrl);
|
||||
router.refresh();
|
||||
router.push(ROUTES.DASHBOARD);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { AuthOptions, User } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials.password) {
|
||||
throw new Error('Missing email or password');
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await authService.login({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
});
|
||||
|
||||
const accessToken = data.accessToken ?? data.token;
|
||||
const refreshToken = data.refreshToken;
|
||||
const user = data.user;
|
||||
const id = data.userId ?? data.id ?? user?.id ?? user?.email ?? credentials.email;
|
||||
const email = data.email ?? user?.email ?? credentials.email;
|
||||
const name = data.name ?? user?.name ?? null;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Invalid login response');
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(id),
|
||||
name,
|
||||
email,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
} as User;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Authentication failed';
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
},
|
||||
pages: {
|
||||
signIn: ROUTES.LOGIN,
|
||||
error: ROUTES.LOGIN,
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.accessToken = user.accessToken;
|
||||
token.refreshToken = user.refreshToken;
|
||||
}
|
||||
|
||||
if (trigger === 'update' && session?.user) {
|
||||
return { ...token, ...session.user };
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id;
|
||||
session.accessToken = token.accessToken;
|
||||
session.refreshToken = token.refreshToken;
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
55
src/providers/AppInitializer.tsx
Normal file
55
src/providers/AppInitializer.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { initializeAuthenticatedApp } from '@/services/initializer.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function AppInitializer({ children }: { children: ReactNode }) {
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const user = useAppStore((state) => state.user);
|
||||
const setLoading = useAppStore((state) => state.setLoading);
|
||||
const setLoaded = useAppStore((state) => state.setLoaded);
|
||||
const setLoadError = useAppStore((state) => state.setLoadError);
|
||||
const clearApp = useAppStore((state) => state.clear);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const initialize = async () => {
|
||||
if (!accessToken || user) {
|
||||
console.debug('[auth] app-init:skip', {
|
||||
hasToken: !!accessToken,
|
||||
hasUser: !!user,
|
||||
});
|
||||
setLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading();
|
||||
console.debug('[auth] app-init:start');
|
||||
|
||||
try {
|
||||
await initializeAuthenticatedApp();
|
||||
console.debug('[auth] app-init:success');
|
||||
} catch {
|
||||
if (isActive) {
|
||||
console.debug('[auth] app-init:failed, logging out');
|
||||
logout();
|
||||
clearApp();
|
||||
setLoadError();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [accessToken, clearApp, logout, setLoaded, setLoadError, setLoading, user]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export default function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
38
src/proxy.ts
38
src/proxy.ts
@@ -1,38 +0,0 @@
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
const isLoginPage = request.nextUrl.pathname === ROUTES.LOGIN;
|
||||
|
||||
if (!token && !isLoginPage) {
|
||||
const url = new URL(ROUTES.LOGIN, request.url);
|
||||
url.searchParams.set('callbackUrl', request.url);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (token && isLoginPage) {
|
||||
return NextResponse.redirect(new URL(ROUTES.DASHBOARD, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/',
|
||||
'/login',
|
||||
// '/dashboard/:path*',
|
||||
// '/project/:path*',
|
||||
// '/package/:path*',
|
||||
// '/chainage/:path*',
|
||||
// '/segment/:path*',
|
||||
// '/upload/:path*',
|
||||
// '/results/:path*',
|
||||
],
|
||||
};
|
||||
@@ -1,9 +1,28 @@
|
||||
import type { AuthResponseData, LoginPayload } from '@/types';
|
||||
import { axiosAuth } from '../axios/axios';
|
||||
import type { AuthResponseData, LoginPayload, MeResponse, PermissionResponse } from '@/types';
|
||||
import axiosClient, { axiosAuth } from '../axios/axios';
|
||||
|
||||
export const authService = {
|
||||
login: async (payload: LoginPayload): Promise<AuthResponseData> => {
|
||||
const response = await axiosAuth.post<AuthResponseData>('/auth/login', payload);
|
||||
const response = await axiosAuth.post<AuthResponseData>('api/auth/login', payload, {
|
||||
withCredentials: true,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
refresh: async (): Promise<AuthResponseData> => {
|
||||
const response = await axiosAuth.post<AuthResponseData>('api/auth/refresh', {}, {
|
||||
withCredentials: true,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
logout: async (): Promise<void> => {
|
||||
await axiosAuth.post('api/auth/logout', {}, { withCredentials: true });
|
||||
},
|
||||
me: async (): Promise<MeResponse> => {
|
||||
const response = await axiosClient.get<MeResponse>('api/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
permissions: async (): Promise<PermissionResponse> => {
|
||||
const response = await axiosClient.get<PermissionResponse>('api/permissions/my-permissions');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
PaginationParams,
|
||||
} from '@/types';
|
||||
|
||||
const CHAINAGES_ENDPOINT = 'biz/api/v1/chainages';
|
||||
|
||||
/**
|
||||
* Chainage Service
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ export const chainageService = {
|
||||
getChainages: async (params?: PaginationParams): Promise<PaginatedResponse<Chainage>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(`/chainages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(CHAINAGES_ENDPOINT, {
|
||||
params: { skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -32,7 +34,7 @@ export const chainageService = {
|
||||
): Promise<PaginatedResponse<Chainage>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(`/chainages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Chainage>>(CHAINAGES_ENDPOINT, {
|
||||
params: { package_id: packageId, skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -42,7 +44,7 @@ export const chainageService = {
|
||||
* Create a new chainage
|
||||
*/
|
||||
createChainage: async (data: ChainageCreate): Promise<Chainage> => {
|
||||
const response = await axiosClient.post<Chainage>('/chainages/', data);
|
||||
const response = await axiosClient.post<Chainage>(CHAINAGES_ENDPOINT, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -50,7 +52,7 @@ export const chainageService = {
|
||||
* Update an existing chainage
|
||||
*/
|
||||
updateChainage: async (chainageId: string, data: ChainageUpdate): Promise<Chainage> => {
|
||||
const response = await axiosClient.put<Chainage>(`/chainages/${chainageId}`, data);
|
||||
const response = await axiosClient.put<Chainage>(`${CHAINAGES_ENDPOINT}/${chainageId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -58,7 +60,9 @@ export const chainageService = {
|
||||
* Delete a chainage
|
||||
*/
|
||||
deleteChainage: async (chainageId: string): Promise<{ message: string }> => {
|
||||
const response = await axiosClient.delete<{ message: string }>(`/chainages/${chainageId}`);
|
||||
const response = await axiosClient.delete<{ message: string }>(
|
||||
`${CHAINAGES_ENDPOINT}/${chainageId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,7 +31,9 @@ export const detectionService = {
|
||||
};
|
||||
};
|
||||
};
|
||||
}>(`/summary/projects/${project.id}`);
|
||||
}>('biz/api/v1/dashboard/overview', {
|
||||
params: { project_id: project.id },
|
||||
});
|
||||
|
||||
const summary = response.data;
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
PaginationParams,
|
||||
} from '@/types';
|
||||
|
||||
const PACKAGES_ENDPOINT = 'biz/api/v1/packages';
|
||||
|
||||
/**
|
||||
* Package Service
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ export const packageService = {
|
||||
getPackages: async (params?: PaginationParams): Promise<PaginatedResponse<Package>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(`/packages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(PACKAGES_ENDPOINT, {
|
||||
params: { skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -32,7 +34,7 @@ export const packageService = {
|
||||
): Promise<PaginatedResponse<Package>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(`/packages/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Package>>(PACKAGES_ENDPOINT, {
|
||||
params: { project_id: projectId, skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -42,7 +44,7 @@ export const packageService = {
|
||||
* Create a new package
|
||||
*/
|
||||
createPackage: async (data: PackageCreate): Promise<Package> => {
|
||||
const response = await axiosClient.post<Package>('/packages/', data);
|
||||
const response = await axiosClient.post<Package>(PACKAGES_ENDPOINT, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -50,7 +52,7 @@ export const packageService = {
|
||||
* Update an existing package
|
||||
*/
|
||||
updatePackage: async (packageId: string, data: PackageUpdate): Promise<Package> => {
|
||||
const response = await axiosClient.put<Package>(`/packages/${packageId}`, data);
|
||||
const response = await axiosClient.put<Package>(`${PACKAGES_ENDPOINT}/${packageId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -58,7 +60,9 @@ export const packageService = {
|
||||
* Delete a package
|
||||
*/
|
||||
deletePackage: async (packageId: string): Promise<{ message: string }> => {
|
||||
const response = await axiosClient.delete<{ message: string }>(`/packages/${packageId}`);
|
||||
const response = await axiosClient.delete<{ message: string }>(
|
||||
`${PACKAGES_ENDPOINT}/${packageId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
PaginationParams,
|
||||
} from '@/types';
|
||||
|
||||
const PROJECTS_ENDPOINT = 'biz/api/v1/projects';
|
||||
const PROJECT_SUMMARY_ENDPOINT = 'biz/api/v1/dashboard/overview';
|
||||
|
||||
/**
|
||||
* Project Service
|
||||
*/
|
||||
@@ -17,7 +20,7 @@ export const projectService = {
|
||||
getProjects: async (params?: PaginationParams): Promise<PaginatedResponse<Project>> => {
|
||||
const skip = params?.skip ?? 0;
|
||||
const limit = params?.limit ?? 100;
|
||||
const response = await axiosClient.get<PaginatedResponse<Project>>(`/projects/`, {
|
||||
const response = await axiosClient.get<PaginatedResponse<Project>>(PROJECTS_ENDPOINT, {
|
||||
params: { skip, limit },
|
||||
});
|
||||
return response.data;
|
||||
@@ -27,7 +30,7 @@ export const projectService = {
|
||||
* Create a new project
|
||||
*/
|
||||
createProject: async (data: ProjectCreate): Promise<Project> => {
|
||||
const response = await axiosClient.post<Project>('/projects/', data);
|
||||
const response = await axiosClient.post<Project>(PROJECTS_ENDPOINT, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -35,7 +38,7 @@ export const projectService = {
|
||||
* Update an existing project
|
||||
*/
|
||||
updateProject: async (projectId: string, data: ProjectUpdate): Promise<Project> => {
|
||||
const response = await axiosClient.put<Project>(`/projects/${projectId}`, data);
|
||||
const response = await axiosClient.put<Project>(`${PROJECTS_ENDPOINT}/${projectId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -43,7 +46,9 @@ export const projectService = {
|
||||
* Delete a project
|
||||
*/
|
||||
deleteProject: async (projectId: string): Promise<{ message: string }> => {
|
||||
const response = await axiosClient.delete<{ message: string }>(`/projects/${projectId}`);
|
||||
const response = await axiosClient.delete<{ message: string }>(
|
||||
`${PROJECTS_ENDPOINT}/${projectId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -51,7 +56,9 @@ export const projectService = {
|
||||
* Fetch project summary (detections across packages and chainages)
|
||||
*/
|
||||
getProjectSummary: async (projectId: string): Promise<any> => {
|
||||
const response = await axiosClient.get(`/summary/projects/${projectId}`);
|
||||
const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, {
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -59,8 +66,8 @@ export const projectService = {
|
||||
* Fetch project summary filtered by video ID
|
||||
*/
|
||||
getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise<any> => {
|
||||
const response = await axiosClient.get(`/summary/projects/${projectId}`, {
|
||||
params: { video_id: videoId },
|
||||
const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, {
|
||||
params: { project_id: projectId, video_id: videoId },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ENV_CONSTANT } from '@/constants/secrect.constant';
|
||||
import { tokenService } from '@/services/token.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import { useAuthStore } from '@/store/auth.store';
|
||||
import axios from 'axios';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
const BASE_URL = ENV_CONSTANT.BASE_API_URL;
|
||||
|
||||
@@ -14,8 +14,8 @@ const axiosClient = axios.create({
|
||||
});
|
||||
|
||||
axiosClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
const token = await tokenService.getAccessToken();
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
@@ -31,8 +31,43 @@ axiosClient.interceptors.request.use(
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401 && typeof window !== 'undefined') {
|
||||
await signOut({ callbackUrl: '/login' });
|
||||
const originalRequest = error?.config;
|
||||
const status = error?.response?.status;
|
||||
const requestUrl = originalRequest?.url ?? '';
|
||||
const isAuthRoute =
|
||||
requestUrl.includes('api/auth/login') || requestUrl.includes('api/auth/refresh');
|
||||
|
||||
if (status === 401 && originalRequest && !originalRequest._retry && !isAuthRoute) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const response = await axiosAuth.post(
|
||||
'api/auth/refresh',
|
||||
{},
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
const accessToken = response.data?.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Refresh failed: no access token');
|
||||
}
|
||||
|
||||
useAuthStore.getState().setAccessToken(accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
return axiosClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
useAuthStore.getState().logout();
|
||||
useAppStore.getState().clear();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
||||
16
src/services/initializer.service.ts
Normal file
16
src/services/initializer.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { authService } from '@/services/api/auth.service';
|
||||
import { useAppStore } from '@/store/app.store';
|
||||
import type { UserProfile } from '@/types';
|
||||
|
||||
export async function initializeAuthenticatedApp() {
|
||||
const [{ user, tenant }, permissionsResponse] = await Promise.all([
|
||||
authService.me(),
|
||||
authService.permissions(),
|
||||
]);
|
||||
|
||||
useAppStore.getState().setUserContext({
|
||||
user: user as UserProfile,
|
||||
tenant,
|
||||
permissions: permissionsResponse.granted_permissions ?? [],
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { getSession } from 'next-auth/react';
|
||||
|
||||
export const tokenService = {
|
||||
getAccessToken: async (): Promise<string | null> => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const session = await getSession();
|
||||
return session?.accessToken ?? null;
|
||||
},
|
||||
getRefreshToken: async (): Promise<string | null> => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const session = await getSession();
|
||||
return session?.refreshToken ?? null;
|
||||
},
|
||||
};
|
||||
48
src/store/app.store.ts
Normal file
48
src/store/app.store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { TenantInfo, UserProfile } from '@/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type AppLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
interface AppState {
|
||||
user: UserProfile | null;
|
||||
tenant: TenantInfo | null;
|
||||
permissions: string[];
|
||||
loadStatus: AppLoadStatus;
|
||||
isInitialized: boolean;
|
||||
setLoading: () => void;
|
||||
setLoaded: () => void;
|
||||
setLoadError: () => void;
|
||||
setUserContext: (payload: {
|
||||
user: UserProfile;
|
||||
tenant?: TenantInfo | null;
|
||||
permissions?: string[];
|
||||
}) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set) => ({
|
||||
user: null,
|
||||
tenant: null,
|
||||
permissions: [],
|
||||
loadStatus: 'idle',
|
||||
isInitialized: false,
|
||||
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
|
||||
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
|
||||
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
|
||||
setUserContext: ({ user, tenant = null, permissions = [] }) =>
|
||||
set({
|
||||
user,
|
||||
tenant,
|
||||
permissions,
|
||||
loadStatus: 'loaded',
|
||||
isInitialized: true,
|
||||
}),
|
||||
clear: () =>
|
||||
set({
|
||||
user: null,
|
||||
tenant: null,
|
||||
permissions: [],
|
||||
loadStatus: 'idle',
|
||||
isInitialized: false,
|
||||
}),
|
||||
}));
|
||||
@@ -1,40 +1,40 @@
|
||||
import type { UserProfile } from '@/types';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
|
||||
const getStoredAccessToken = () => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
};
|
||||
|
||||
interface AuthState {
|
||||
user: UserProfile | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (user: UserProfile, accessToken?: string | null, refreshToken?: string | null) => void;
|
||||
setAccessToken: (accessToken: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
export const useAuthStore = create<AuthState>()((set) => ({
|
||||
accessToken: getStoredAccessToken(),
|
||||
isAuthenticated: !!getStoredAccessToken(),
|
||||
setAccessToken: (accessToken) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
}
|
||||
|
||||
set({
|
||||
accessToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (user, accessToken, refreshToken) =>
|
||||
set({
|
||||
user,
|
||||
accessToken: accessToken ?? null,
|
||||
refreshToken: refreshToken ?? null,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
29
src/types/auth.d.ts
vendored
29
src/types/auth.d.ts
vendored
@@ -1,29 +0,0 @@
|
||||
import 'next-auth';
|
||||
import 'next-auth/jwt';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
user: {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,55 @@
|
||||
export type AuthUser = {
|
||||
id?: string | number;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
roles?: Array<{
|
||||
name: string;
|
||||
display_name: string;
|
||||
is_active: boolean;
|
||||
}>;
|
||||
is_superadmin?: boolean;
|
||||
profile_photo_url?: string | null;
|
||||
organization_id?: number;
|
||||
organization_name?: string;
|
||||
};
|
||||
|
||||
export type UserProfile = {
|
||||
id?: string;
|
||||
id?: string | number;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
role?: string | null;
|
||||
roles?: AuthUser['roles'];
|
||||
is_superadmin?: boolean;
|
||||
profile_photo_url?: string | null;
|
||||
organization_id?: number;
|
||||
organization_name?: string;
|
||||
};
|
||||
|
||||
export type TenantInfo = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string | null;
|
||||
type: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
user: AuthUser;
|
||||
tenant: TenantInfo;
|
||||
};
|
||||
|
||||
export type PermissionResponse = {
|
||||
granted_permissions?: string[];
|
||||
tree?: unknown[];
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
@@ -20,12 +59,6 @@ export type LoginPayload = {
|
||||
};
|
||||
|
||||
export type AuthResponseData = {
|
||||
id?: string | number;
|
||||
userId?: string | number;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
token?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
user?: AuthUser;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user