feat: implement auth flow and app initialization
This commit is contained in:
162
package-lock.json
generated
162
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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
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,
|
||||
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
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