feat: implement auth flow and app initialization

This commit is contained in:
2026-06-15 18:41:27 +05:30
parent 9de65d0ce6
commit 8d4ff49633
29 changed files with 551 additions and 535 deletions

162
package-lock.json generated
View File

@@ -27,7 +27,6 @@
"lucide-react": "^0.548.0",
"motion": "^12.38.0",
"next": "16.0.10",
"next-auth": "^4.24.14",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.0",
@@ -46,6 +45,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.37",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.7",
"eslint-config-prettier": "^10.1.8",
@@ -1300,15 +1300,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4598,9 +4589,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4887,15 +4878,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7086,15 +7068,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7901,38 +7874,6 @@
}
}
},
"node_modules/next-auth": {
"version": "4.24.14",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz",
"integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.3",
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
"nodemailer": "^7.0.7",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -8023,12 +7964,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8038,15 +7973,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -8160,15 +8086,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
@@ -8185,39 +8102,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8391,28 +8275,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
"version": "10.29.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8452,12 +8314,6 @@
"node": ">=6.0.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -10077,16 +9933,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@@ -39,7 +39,6 @@
"lucide-react": "^0.548.0",
"motion": "^12.38.0",
"next": "16.0.10",
"next-auth": "^4.24.14",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.0",
@@ -58,6 +57,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.37",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.7",
"eslint-config-prettier": "^10.1.8",

View File

@@ -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,6 +12,7 @@ export default function LoginPage() {
const { register, handleSubmit, errors, isLoading } = useLoginForm();
return (
<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
@@ -99,5 +101,6 @@ export default function LoginPage() {
</div>
</section>
</main>
</GuestGuard>
);
}

View File

@@ -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,6 +12,7 @@ const ModulesLayout = ({
children: React.ReactNode;
}>) => {
return (
<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">
@@ -28,6 +30,7 @@ const ModulesLayout = ({
</div>
</main>
</SidebarProvider>
</AuthGuard>
);
};

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
Log out
{isLoggingOut ? 'Logging out' : 'Log out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

30
src/guards/AuthGuard.tsx Normal file
View 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
View 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;
}

View 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
View File

@@ -0,0 +1,3 @@
export * from './AuthGuard';
export * from './GuestGuard';
export * from './PermissionGuard';

View File

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

View File

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

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

View File

@@ -1,7 +0,0 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export default function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -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*',
],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 ?? [],
});
}

View File

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

View File

@@ -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,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
export const useAuthStore = create<AuthState>()((set) => ({
accessToken: getStoredAccessToken(),
isAuthenticated: !!getStoredAccessToken(),
setAccessToken: (accessToken) => {
if (typeof window !== 'undefined') {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
}
set({
user,
accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null,
accessToken,
isAuthenticated: true,
}),
logout: () =>
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage',
});
},
),
);
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem(ACCESS_TOKEN_KEY);
}
set({
accessToken: null,
isAuthenticated: false,
});
},
}));

29
src/types/auth.d.ts vendored
View File

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

View File

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