diff --git a/package-lock.json b/package-lock.json index 39086c8..740dca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0d9a60f..ae0ddde 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(full-page)/login/page.tsx b/src/app/(full-page)/login/page.tsx index dcffe62..14daaf9 100644 --- a/src/app/(full-page)/login/page.tsx +++ b/src/app/(full-page)/login/page.tsx @@ -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 ( -
-
- -
-
-
-

- VisionRoad -

-

- Road intelligence for faster inspection decisions -

-

- Review projects, uploads, detections, and chainage insights from one secure - workspace. -

+ +
+
+ +
+
+
+

+ VisionRoad +

+

+ Road intelligence for faster inspection decisions +

+

+ Review projects, uploads, detections, and chainage insights from one secure + workspace. +

+
-
-
+
-
-
-
-

VisionRoad

-

Sign in

-

- Use your email and password to continue. -

-
+
+
+
+

VisionRoad

+

Sign in

+

+ Use your email and password to continue. +

+
-
-
- - - {errors.email && ( -

{errors.email.message}

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

{errors.email.message}

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

{errors.password.message}

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

{errors.password.message}

+ )} +
- -
-
-
-
+ + + + + + ); } diff --git a/src/app/(modules)/layout.tsx b/src/app/(modules)/layout.tsx index 80da669..be88edb 100644 --- a/src/app/(modules)/layout.tsx +++ b/src/app/(modules)/layout.tsx @@ -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 ( - - -
-
- {/* Centering container wrapper */} -
-
- - - - + + + +
+
+ {/* Centering container wrapper */} +
+
+ + + + +
+
{children}
-
{children}
-
-
-
+ + + ); }; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 198dec6..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -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 }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 02d1c83..7c8165c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 > - +
{children}
-
+
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 93f61f5..efe13a2 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -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) { 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 ( @@ -87,7 +89,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index e9e1af1..cfb5844 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -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 ( @@ -91,9 +118,15 @@ export function NavUser({ - - - Log out + { + event.preventDefault(); + handleLogout(); + }} + disabled={isLoggingOut} + > + + {isLoggingOut ? 'Logging out' : 'Log out'} diff --git a/src/guards/AuthGuard.tsx b/src/guards/AuthGuard.tsx new file mode 100644 index 0000000..2250df6 --- /dev/null +++ b/src/guards/AuthGuard.tsx @@ -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; +} diff --git a/src/guards/GuestGuard.tsx b/src/guards/GuestGuard.tsx new file mode 100644 index 0000000..9edfb13 --- /dev/null +++ b/src/guards/GuestGuard.tsx @@ -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; +} diff --git a/src/guards/PermissionGuard.tsx b/src/guards/PermissionGuard.tsx new file mode 100644 index 0000000..01d3d82 --- /dev/null +++ b/src/guards/PermissionGuard.tsx @@ -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; +} diff --git a/src/guards/index.ts b/src/guards/index.ts new file mode 100644 index 0000000..0caa0c0 --- /dev/null +++ b/src/guards/index.ts @@ -0,0 +1,3 @@ +export * from './AuthGuard'; +export * from './GuestGuard'; +export * from './PermissionGuard'; diff --git a/src/hooks/useLoginForm.ts b/src/hooks/useLoginForm.ts index b6f391b..ef297a4 100644 --- a/src/hooks/useLoginForm.ts +++ b/src/hooks/useLoginForm.ts @@ -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'); } diff --git a/src/lib/auth/authOptions.ts b/src/lib/auth/authOptions.ts deleted file mode 100644 index d59f715..0000000 --- a/src/lib/auth/authOptions.ts +++ /dev/null @@ -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, -}; diff --git a/src/providers/AppInitializer.tsx b/src/providers/AppInitializer.tsx new file mode 100644 index 0000000..c8acee6 --- /dev/null +++ b/src/providers/AppInitializer.tsx @@ -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; +} diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx deleted file mode 100644 index be51c14..0000000 --- a/src/providers/AuthProvider.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import { SessionProvider } from 'next-auth/react'; - -export default function AuthProvider({ children }: { children: React.ReactNode }) { - return {children}; -} diff --git a/src/proxy.ts b/src/proxy.ts deleted file mode 100644 index 2a721d5..0000000 --- a/src/proxy.ts +++ /dev/null @@ -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*', - ], -}; diff --git a/src/services/api/auth.service.ts b/src/services/api/auth.service.ts index 36a670a..c8bff86 100644 --- a/src/services/api/auth.service.ts +++ b/src/services/api/auth.service.ts @@ -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 => { - const response = await axiosAuth.post('/auth/login', payload); + const response = await axiosAuth.post('api/auth/login', payload, { + withCredentials: true, + }); + return response.data; + }, + refresh: async (): Promise => { + const response = await axiosAuth.post('api/auth/refresh', {}, { + withCredentials: true, + }); + return response.data; + }, + logout: async (): Promise => { + await axiosAuth.post('api/auth/logout', {}, { withCredentials: true }); + }, + me: async (): Promise => { + const response = await axiosClient.get('api/auth/me'); + return response.data; + }, + permissions: async (): Promise => { + const response = await axiosClient.get('api/permissions/my-permissions'); return response.data; }, }; diff --git a/src/services/api/chainage.service.ts b/src/services/api/chainage.service.ts index 975ae5e..47d724c 100644 --- a/src/services/api/chainage.service.ts +++ b/src/services/api/chainage.service.ts @@ -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> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(`/chainages/`, { + const response = await axiosClient.get>(CHAINAGES_ENDPOINT, { params: { skip, limit }, }); return response.data; @@ -32,7 +34,7 @@ export const chainageService = { ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(`/chainages/`, { + const response = await axiosClient.get>(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 => { - const response = await axiosClient.post('/chainages/', data); + const response = await axiosClient.post(CHAINAGES_ENDPOINT, data); return response.data; }, @@ -50,7 +52,7 @@ export const chainageService = { * Update an existing chainage */ updateChainage: async (chainageId: string, data: ChainageUpdate): Promise => { - const response = await axiosClient.put(`/chainages/${chainageId}`, data); + const response = await axiosClient.put(`${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; }, }; diff --git a/src/services/api/detection.service.ts b/src/services/api/detection.service.ts index f7a259e..9246e76 100644 --- a/src/services/api/detection.service.ts +++ b/src/services/api/detection.service.ts @@ -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; diff --git a/src/services/api/package.service.ts b/src/services/api/package.service.ts index 41e01af..5c44540 100644 --- a/src/services/api/package.service.ts +++ b/src/services/api/package.service.ts @@ -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> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(`/packages/`, { + const response = await axiosClient.get>(PACKAGES_ENDPOINT, { params: { skip, limit }, }); return response.data; @@ -32,7 +34,7 @@ export const packageService = { ): Promise> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(`/packages/`, { + const response = await axiosClient.get>(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 => { - const response = await axiosClient.post('/packages/', data); + const response = await axiosClient.post(PACKAGES_ENDPOINT, data); return response.data; }, @@ -50,7 +52,7 @@ export const packageService = { * Update an existing package */ updatePackage: async (packageId: string, data: PackageUpdate): Promise => { - const response = await axiosClient.put(`/packages/${packageId}`, data); + const response = await axiosClient.put(`${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; }, }; diff --git a/src/services/api/project.service.ts b/src/services/api/project.service.ts index 4ba43bf..c339f72 100644 --- a/src/services/api/project.service.ts +++ b/src/services/api/project.service.ts @@ -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> => { const skip = params?.skip ?? 0; const limit = params?.limit ?? 100; - const response = await axiosClient.get>(`/projects/`, { + const response = await axiosClient.get>(PROJECTS_ENDPOINT, { params: { skip, limit }, }); return response.data; @@ -27,7 +30,7 @@ export const projectService = { * Create a new project */ createProject: async (data: ProjectCreate): Promise => { - const response = await axiosClient.post('/projects/', data); + const response = await axiosClient.post(PROJECTS_ENDPOINT, data); return response.data; }, @@ -35,7 +38,7 @@ export const projectService = { * Update an existing project */ updateProject: async (projectId: string, data: ProjectUpdate): Promise => { - const response = await axiosClient.put(`/projects/${projectId}`, data); + const response = await axiosClient.put(`${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 => { - 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 => { - 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; }, diff --git a/src/services/axios/axios.ts b/src/services/axios/axios.ts index 1d025c2..f5287ed 100644 --- a/src/services/axios/axios.ts +++ b/src/services/axios/axios.ts @@ -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); diff --git a/src/services/initializer.service.ts b/src/services/initializer.service.ts new file mode 100644 index 0000000..86635a1 --- /dev/null +++ b/src/services/initializer.service.ts @@ -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 ?? [], + }); +} diff --git a/src/services/token.service.ts b/src/services/token.service.ts deleted file mode 100644 index 2760a96..0000000 --- a/src/services/token.service.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getSession } from 'next-auth/react'; - -export const tokenService = { - getAccessToken: async (): Promise => { - if (typeof window === 'undefined') return null; - - const session = await getSession(); - return session?.accessToken ?? null; - }, - getRefreshToken: async (): Promise => { - if (typeof window === 'undefined') return null; - - const session = await getSession(); - return session?.refreshToken ?? null; - }, -}; diff --git a/src/store/app.store.ts b/src/store/app.store.ts new file mode 100644 index 0000000..5d7e5d7 --- /dev/null +++ b/src/store/app.store.ts @@ -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()((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, + }), +})); diff --git a/src/store/auth.store.ts b/src/store/auth.store.ts index 432b760..15d1970 100644 --- a/src/store/auth.store.ts +++ b/src/store/auth.store.ts @@ -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()( - persist( - (set) => ({ - user: null, +export const useAuthStore = create()((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', - }, - ), -); + }); + }, +})); diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts deleted file mode 100644 index 0de3e25..0000000 --- a/src/types/auth.d.ts +++ /dev/null @@ -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; - } -} diff --git a/src/types/auth.type.ts b/src/types/auth.type.ts index 64d07cb..01167ab 100644 --- a/src/types/auth.type.ts +++ b/src/types/auth.type.ts @@ -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; };