feat: implement auth flow and app initialization

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

162
package-lock.json generated
View File

@@ -27,7 +27,6 @@
"lucide-react": "^0.548.0", "lucide-react": "^0.548.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.0.10", "next": "16.0.10",
"next-auth": "^4.24.14",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.0", "react": "19.2.0",
@@ -46,6 +45,7 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"baseline-browser-mapping": "^2.10.37",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.1.7", "eslint-config-next": "^16.1.7",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -1300,15 +1300,6 @@
"node": ">=12.4.0" "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": { "node_modules/@pkgr/core": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4598,9 +4589,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.8", "version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -4887,15 +4878,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7086,15 +7068,6 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "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": { "node_modules/next-themes": {
"version": "0.4.6", "version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -8023,12 +7964,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8038,15 +7973,6 @@
"node": ">=0.10.0" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -8160,15 +8086,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/onetime": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
@@ -8185,39 +8102,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8391,28 +8275,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8452,12 +8314,6 @@
"node": ">=6.0.0" "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": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "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" "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": { "node_modules/victory-vendor": {
"version": "36.9.2", "version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

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

View File

@@ -3,6 +3,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { GuestGuard } from '@/guards';
import { useLoginForm } from '@/hooks/useLoginForm'; import { useLoginForm } from '@/hooks/useLoginForm';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
@@ -11,93 +12,95 @@ export default function LoginPage() {
const { register, handleSubmit, errors, isLoading } = useLoginForm(); const { register, handleSubmit, errors, isLoading } = useLoginForm();
return ( return (
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]"> <GuestGuard>
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block"> <main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
<Image <section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
src="/background.png" <Image
alt="" src="/background.png"
fill alt=""
priority fill
className="object-cover" priority
sizes="55vw" className="object-cover"
/> sizes="55vw"
<div className="absolute inset-0 bg-background/35" /> />
<div className="absolute inset-x-0 bottom-0 p-10"> <div className="absolute inset-0 bg-background/35" />
<div className="max-w-lg space-y-3"> <div className="absolute inset-x-0 bottom-0 p-10">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary"> <div className="max-w-lg space-y-3">
VisionRoad <p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
</p> VisionRoad
<h1 className="text-4xl font-semibold tracking-normal text-foreground"> </p>
Road intelligence for faster inspection decisions <h1 className="text-4xl font-semibold tracking-normal text-foreground">
</h1> Road intelligence for faster inspection decisions
<p className="text-sm leading-6 text-muted-foreground"> </h1>
Review projects, uploads, detections, and chainage insights from one secure <p className="text-sm leading-6 text-muted-foreground">
workspace. Review projects, uploads, detections, and chainage insights from one secure
</p> workspace.
</p>
</div>
</div> </div>
</div> </section>
</section>
<section className="flex min-h-screen items-center justify-center px-6 py-10"> <section className="flex min-h-screen items-center justify-center px-6 py-10">
<div className="w-full max-w-sm space-y-7"> <div className="w-full max-w-sm space-y-7">
<header className="space-y-2"> <header className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p> <p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2> <h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Use your email and password to continue. Use your email and password to continue.
</p> </p>
</header> </header>
<form className="space-y-5" onSubmit={handleSubmit}> <form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="admin@visionroad.ai" placeholder="admin@visionroad.ai"
autoComplete="email" autoComplete="email"
disabled={isLoading} disabled={isLoading}
aria-invalid={!!errors.email} aria-invalid={!!errors.email}
{...register('email', { {...register('email', {
required: 'Email is required', required: 'Email is required',
})} })}
/> />
{errors.email && ( {errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p> <p className="text-sm text-destructive">{errors.email.message}</p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
placeholder="Enter password" placeholder="Enter password"
autoComplete="current-password" autoComplete="current-password"
disabled={isLoading} disabled={isLoading}
aria-invalid={!!errors.password} aria-invalid={!!errors.password}
{...register('password', { {...register('password', {
required: 'Password is required', required: 'Password is required',
})} })}
/> />
{errors.password && ( {errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p> <p className="text-sm text-destructive">{errors.password.message}</p>
)} )}
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Signing in Signing in
</> </>
) : ( ) : (
'Sign in' 'Sign in'
)} )}
</Button> </Button>
</form> </form>
</div> </div>
</section> </section>
</main> </main>
</GuestGuard>
); );
} }

View File

@@ -3,6 +3,7 @@ import { AppSidebar } from '@/components/app-sidebar';
import { ModeToggle } from '@/components/mode-toogle'; import { ModeToggle } from '@/components/mode-toogle';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { AuthGuard } from '@/guards';
import React from 'react'; import React from 'react';
const ModulesLayout = ({ const ModulesLayout = ({
@@ -11,23 +12,25 @@ const ModulesLayout = ({
children: React.ReactNode; children: React.ReactNode;
}>) => { }>) => {
return ( return (
<SidebarProvider className="bg-transparent overflow-hidden"> <AuthGuard>
<AppSidebar className="bg-transparent" /> <SidebarProvider className="bg-transparent overflow-hidden">
<main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent"> <AppSidebar className="bg-transparent" />
<div className="flex-1 overflow-auto"> <main className="flex flex-1 flex-col w-full h-screen overflow-hidden bg-transparent">
{/* Centering container wrapper */} <div className="flex-1 overflow-auto">
<div className="max-w-380 mx-auto w-full flex flex-col min-h-full"> {/* Centering container wrapper */}
<div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10"> <div className="max-w-380 mx-auto w-full flex flex-col min-h-full">
<SidebarTrigger /> <div className="flex gap-3 items-center sticky top-0 bg-background/30 backdrop-blur-xl z-30 py-3 px-6 border-b border-border/10">
<ModeToggle /> <SidebarTrigger />
<Separator orientation="vertical" className="mx-2 h-4" /> <ModeToggle />
<BreadcrumbBasic /> <Separator orientation="vertical" className="mx-2 h-4" />
<BreadcrumbBasic />
</div>
<div className="p-6 flex-1">{children}</div>
</div> </div>
<div className="p-6 flex-1">{children}</div>
</div> </div>
</div> </main>
</main> </SidebarProvider>
</SidebarProvider> </AuthGuard>
); );
}; };

View File

@@ -1,6 +0,0 @@
import { authOptions } from '@/lib/auth/authOptions';
import NextAuth from 'next-auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google'; import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css'; import './globals.css';
import { ThemeProvider } from '@/providers/ThemeProvider'; import { ThemeProvider } from '@/providers/ThemeProvider';
import AuthProvider from '@/providers/AuthProvider'; import AppInitializer from '@/providers/AppInitializer';
import QueryProvider from '@/providers/QueryProvider'; import QueryProvider from '@/providers/QueryProvider';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { BackgroundGradient } from '@/components/background-gradient'; import { BackgroundGradient } from '@/components/background-gradient';
@@ -37,13 +37,13 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<QueryProvider> <QueryProvider>
<AuthProvider> <AppInitializer>
<div className="relative min-h-screen isolate"> <div className="relative min-h-screen isolate">
<BackgroundGradient /> <BackgroundGradient />
{children} {children}
</div> </div>
<Toaster position="top-center" /> <Toaster position="top-center" />
</AuthProvider> </AppInitializer>
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import * as React from 'react'; 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 { NavUser } from '@/components/nav-user';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { import {
@@ -17,14 +17,10 @@ import {
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useAppStore } from '@/store/app.store';
// This is the navigation data // This is the navigation data
const data = { const data = {
user: {
name: 'Vision Admin',
email: 'admin@visionroad.ai',
avatar: '/avatars/profile.jpg',
},
navMain: [ navMain: [
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -56,6 +52,12 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname(); 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 ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
@@ -87,7 +89,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavUser user={data.user} /> <NavUser user={user} />
</SidebarFooter> </SidebarFooter>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react'; import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { import {
@@ -18,6 +19,11 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from '@/components/ui/sidebar'; } 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({ export function NavUser({
user, user,
@@ -29,6 +35,27 @@ export function NavUser({
}; };
}) { }) {
const { isMobile } = useSidebar(); 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 ( return (
<SidebarMenu> <SidebarMenu>
@@ -91,9 +118,15 @@ export function NavUser({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem
<LogOut /> onSelect={(event) => {
Log out event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

30
src/guards/AuthGuard.tsx Normal file
View File

@@ -0,0 +1,30 @@
'use client';
import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store';
import { ROUTES } from '@/utils/routes';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
export function AuthGuard({ children }: { children: ReactNode }) {
const router = useRouter();
const accessToken = useAuthStore((state) => state.accessToken);
const isInitialized = useAppStore((state) => state.isInitialized);
const user = useAppStore((state) => state.user);
useEffect(() => {
if (!isInitialized) return;
if (!accessToken) {
console.debug('[auth] guard:redirect-login');
router.replace(ROUTES.LOGIN);
}
}, [accessToken, isInitialized, router]);
if (!isInitialized || !accessToken || !user) {
return null;
}
return children;
}

26
src/guards/GuestGuard.tsx Normal file
View File

@@ -0,0 +1,26 @@
'use client';
import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store';
import { ROUTES } from '@/utils/routes';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
export function GuestGuard({ children }: { children: ReactNode }) {
const router = useRouter();
const accessToken = useAuthStore((state) => state.accessToken);
const isInitialized = useAppStore((state) => state.isInitialized);
const user = useAppStore((state) => state.user);
useEffect(() => {
if (isInitialized && accessToken && user) {
console.debug('[auth] guest-guard:redirect-dashboard');
router.replace(ROUTES.DASHBOARD);
}
}, [accessToken, isInitialized, router, user]);
if (!isInitialized || (accessToken && user)) return null;
return children;
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useAppStore } from '@/store/app.store';
import type { ReactNode } from 'react';
type PermissionGuardProps = {
children: ReactNode;
permissions: string[];
mode?: 'all' | 'any';
fallback?: ReactNode;
};
export function PermissionGuard({
children,
permissions,
mode = 'all',
fallback = null,
}: PermissionGuardProps) {
const grantedPermissions = useAppStore((state) => state.permissions);
const normalizedPermissions = grantedPermissions.map((permission) => permission.toLowerCase());
const hasPermission =
mode === 'all'
? permissions.every((permission) => normalizedPermissions.includes(permission.toLowerCase()))
: permissions.some((permission) => normalizedPermissions.includes(permission.toLowerCase()));
if (!hasPermission) return fallback;
return children;
}

3
src/guards/index.ts Normal file
View File

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

View File

@@ -1,14 +1,17 @@
import { authService } from '@/services/api/auth.service';
import { initializeAuthenticatedApp } from '@/services/initializer.service';
import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store'; import { useAuthStore } from '@/store/auth.store';
import type { LoginPayload, UserProfile } from '@/types'; import type { LoginPayload } from '@/types';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
import { getSession, signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const useLoginForm = () => { export const useLoginForm = () => {
const router = useRouter(); const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth); const setAccessToken = useAuthStore((state) => state.setAccessToken);
const setLoading = useAppStore((state) => state.setLoading);
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -23,32 +26,26 @@ export const useLoginForm = () => {
const onSubmit = async (data: LoginPayload) => { const onSubmit = async (data: LoginPayload) => {
try { try {
const callbackUrl = console.debug('[auth] login:start');
new URLSearchParams(window.location.search).get('callbackUrl') ?? ROUTES.DASHBOARD; const loginResponse = await authService.login({
const result = await signIn('credentials', {
email: data.email, email: data.email,
password: data.password, password: data.password,
redirect: false,
callbackUrl,
}); });
const accessToken = loginResponse.access_token;
if (result?.error) { if (!accessToken) {
toast.error(result.error); toast.error('Login failed: no access token returned');
return; return;
} }
const session = await getSession(); setAccessToken(accessToken);
if (session?.user) { setLoading();
setAuth( console.debug('[auth] login:token-stored');
session.user as UserProfile, await initializeAuthenticatedApp();
session.accessToken ?? null, console.debug('[auth] login:initialized, redirecting', ROUTES.DASHBOARD);
session.refreshToken ?? null,
);
}
toast.success('Login successful'); toast.success('Login successful');
router.push(result?.url ?? callbackUrl); router.push(ROUTES.DASHBOARD);
router.refresh();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to sign in'); toast.error(error instanceof Error ? error.message : 'Unable to sign in');
} }

View File

@@ -1,83 +0,0 @@
import { authService } from '@/services/api/auth.service';
import { ROUTES } from '@/utils/routes';
import type { AuthOptions, User } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials.password) {
throw new Error('Missing email or password');
}
try {
const data = await authService.login({
email: credentials.email,
password: credentials.password,
});
const accessToken = data.accessToken ?? data.token;
const refreshToken = data.refreshToken;
const user = data.user;
const id = data.userId ?? data.id ?? user?.id ?? user?.email ?? credentials.email;
const email = data.email ?? user?.email ?? credentials.email;
const name = data.name ?? user?.name ?? null;
if (!accessToken) {
throw new Error('Invalid login response');
}
return {
id: String(id),
name,
email,
accessToken,
refreshToken,
} as User;
} catch (error) {
const message =
error instanceof Error ? error.message : 'Authentication failed';
throw new Error(message);
}
},
}),
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60,
},
pages: {
signIn: ROUTES.LOGIN,
error: ROUTES.LOGIN,
},
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id;
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
}
if (trigger === 'update' && session?.user) {
return { ...token, ...session.user };
}
return token;
},
async session({ session, token }) {
session.user.id = token.id;
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};

View File

@@ -0,0 +1,55 @@
'use client';
import { initializeAuthenticatedApp } from '@/services/initializer.service';
import { useAppStore } from '@/store/app.store';
import { useAuthStore } from '@/store/auth.store';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
export default function AppInitializer({ children }: { children: ReactNode }) {
const accessToken = useAuthStore((state) => state.accessToken);
const logout = useAuthStore((state) => state.logout);
const user = useAppStore((state) => state.user);
const setLoading = useAppStore((state) => state.setLoading);
const setLoaded = useAppStore((state) => state.setLoaded);
const setLoadError = useAppStore((state) => state.setLoadError);
const clearApp = useAppStore((state) => state.clear);
useEffect(() => {
let isActive = true;
const initialize = async () => {
if (!accessToken || user) {
console.debug('[auth] app-init:skip', {
hasToken: !!accessToken,
hasUser: !!user,
});
setLoaded();
return;
}
setLoading();
console.debug('[auth] app-init:start');
try {
await initializeAuthenticatedApp();
console.debug('[auth] app-init:success');
} catch {
if (isActive) {
console.debug('[auth] app-init:failed, logging out');
logout();
clearApp();
setLoadError();
}
}
};
initialize();
return () => {
isActive = false;
};
}, [accessToken, clearApp, logout, setLoaded, setLoadError, setLoading, user]);
return children;
}

View File

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

View File

@@ -1,38 +0,0 @@
import { ROUTES } from '@/utils/routes';
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function proxy(request: NextRequest) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
const isLoginPage = request.nextUrl.pathname === ROUTES.LOGIN;
if (!token && !isLoginPage) {
const url = new URL(ROUTES.LOGIN, request.url);
url.searchParams.set('callbackUrl', request.url);
return NextResponse.redirect(url);
}
if (token && isLoginPage) {
return NextResponse.redirect(new URL(ROUTES.DASHBOARD, request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/',
'/login',
// '/dashboard/:path*',
// '/project/:path*',
// '/package/:path*',
// '/chainage/:path*',
// '/segment/:path*',
// '/upload/:path*',
// '/results/:path*',
],
};

View File

@@ -1,9 +1,28 @@
import type { AuthResponseData, LoginPayload } from '@/types'; import type { AuthResponseData, LoginPayload, MeResponse, PermissionResponse } from '@/types';
import { axiosAuth } from '../axios/axios'; import axiosClient, { axiosAuth } from '../axios/axios';
export const authService = { export const authService = {
login: async (payload: LoginPayload): Promise<AuthResponseData> => { 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; return response.data;
}, },
}; };

View File

@@ -7,6 +7,8 @@ import {
PaginationParams, PaginationParams,
} from '@/types'; } from '@/types';
const CHAINAGES_ENDPOINT = 'biz/api/v1/chainages';
/** /**
* Chainage Service * Chainage Service
*/ */
@@ -17,7 +19,7 @@ export const chainageService = {
getChainages: async (params?: PaginationParams): Promise<PaginatedResponse<Chainage>> => { getChainages: async (params?: PaginationParams): Promise<PaginatedResponse<Chainage>> => {
const skip = params?.skip ?? 0; const skip = params?.skip ?? 0;
const limit = params?.limit ?? 100; 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 }, params: { skip, limit },
}); });
return response.data; return response.data;
@@ -32,7 +34,7 @@ export const chainageService = {
): Promise<PaginatedResponse<Chainage>> => { ): Promise<PaginatedResponse<Chainage>> => {
const skip = params?.skip ?? 0; const skip = params?.skip ?? 0;
const limit = params?.limit ?? 100; 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 }, params: { package_id: packageId, skip, limit },
}); });
return response.data; return response.data;
@@ -42,7 +44,7 @@ export const chainageService = {
* Create a new chainage * Create a new chainage
*/ */
createChainage: async (data: ChainageCreate): Promise<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; return response.data;
}, },
@@ -50,7 +52,7 @@ export const chainageService = {
* Update an existing chainage * Update an existing chainage
*/ */
updateChainage: async (chainageId: string, data: ChainageUpdate): Promise<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; return response.data;
}, },
@@ -58,7 +60,9 @@ export const chainageService = {
* Delete a chainage * Delete a chainage
*/ */
deleteChainage: async (chainageId: string): Promise<{ message: string }> => { 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; return response.data;
}, },
}; };

View File

@@ -31,7 +31,9 @@ export const detectionService = {
}; };
}; };
}; };
}>(`/summary/projects/${project.id}`); }>('biz/api/v1/dashboard/overview', {
params: { project_id: project.id },
});
const summary = response.data; const summary = response.data;

View File

@@ -7,6 +7,8 @@ import {
PaginationParams, PaginationParams,
} from '@/types'; } from '@/types';
const PACKAGES_ENDPOINT = 'biz/api/v1/packages';
/** /**
* Package Service * Package Service
*/ */
@@ -17,7 +19,7 @@ export const packageService = {
getPackages: async (params?: PaginationParams): Promise<PaginatedResponse<Package>> => { getPackages: async (params?: PaginationParams): Promise<PaginatedResponse<Package>> => {
const skip = params?.skip ?? 0; const skip = params?.skip ?? 0;
const limit = params?.limit ?? 100; 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 }, params: { skip, limit },
}); });
return response.data; return response.data;
@@ -32,7 +34,7 @@ export const packageService = {
): Promise<PaginatedResponse<Package>> => { ): Promise<PaginatedResponse<Package>> => {
const skip = params?.skip ?? 0; const skip = params?.skip ?? 0;
const limit = params?.limit ?? 100; 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 }, params: { project_id: projectId, skip, limit },
}); });
return response.data; return response.data;
@@ -42,7 +44,7 @@ export const packageService = {
* Create a new package * Create a new package
*/ */
createPackage: async (data: PackageCreate): Promise<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; return response.data;
}, },
@@ -50,7 +52,7 @@ export const packageService = {
* Update an existing package * Update an existing package
*/ */
updatePackage: async (packageId: string, data: PackageUpdate): Promise<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; return response.data;
}, },
@@ -58,7 +60,9 @@ export const packageService = {
* Delete a package * Delete a package
*/ */
deletePackage: async (packageId: string): Promise<{ message: string }> => { 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; return response.data;
}, },
}; };

View File

@@ -7,6 +7,9 @@ import {
PaginationParams, PaginationParams,
} from '@/types'; } from '@/types';
const PROJECTS_ENDPOINT = 'biz/api/v1/projects';
const PROJECT_SUMMARY_ENDPOINT = 'biz/api/v1/dashboard/overview';
/** /**
* Project Service * Project Service
*/ */
@@ -17,7 +20,7 @@ export const projectService = {
getProjects: async (params?: PaginationParams): Promise<PaginatedResponse<Project>> => { getProjects: async (params?: PaginationParams): Promise<PaginatedResponse<Project>> => {
const skip = params?.skip ?? 0; const skip = params?.skip ?? 0;
const limit = params?.limit ?? 100; 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 }, params: { skip, limit },
}); });
return response.data; return response.data;
@@ -27,7 +30,7 @@ export const projectService = {
* Create a new project * Create a new project
*/ */
createProject: async (data: ProjectCreate): Promise<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; return response.data;
}, },
@@ -35,7 +38,7 @@ export const projectService = {
* Update an existing project * Update an existing project
*/ */
updateProject: async (projectId: string, data: ProjectUpdate): Promise<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; return response.data;
}, },
@@ -43,7 +46,9 @@ export const projectService = {
* Delete a project * Delete a project
*/ */
deleteProject: async (projectId: string): Promise<{ message: string }> => { 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; return response.data;
}, },
@@ -51,7 +56,9 @@ export const projectService = {
* Fetch project summary (detections across packages and chainages) * Fetch project summary (detections across packages and chainages)
*/ */
getProjectSummary: async (projectId: string): Promise<any> => { 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; return response.data;
}, },
@@ -59,8 +66,8 @@ export const projectService = {
* Fetch project summary filtered by video ID * Fetch project summary filtered by video ID
*/ */
getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise<any> => { getProjectSummaryByVideo: async (projectId: string, videoId: string): Promise<any> => {
const response = await axiosClient.get(`/summary/projects/${projectId}`, { const response = await axiosClient.get(PROJECT_SUMMARY_ENDPOINT, {
params: { video_id: videoId }, params: { project_id: projectId, video_id: videoId },
}); });
return response.data; return response.data;
}, },

View File

@@ -1,7 +1,7 @@
import { ENV_CONSTANT } from '@/constants/secrect.constant'; 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 axios from 'axios';
import { signOut } from 'next-auth/react';
const BASE_URL = ENV_CONSTANT.BASE_API_URL; const BASE_URL = ENV_CONSTANT.BASE_API_URL;
@@ -14,8 +14,8 @@ const axiosClient = axios.create({
}); });
axiosClient.interceptors.request.use( axiosClient.interceptors.request.use(
async (config) => { (config) => {
const token = await tokenService.getAccessToken(); const token = useAuthStore.getState().accessToken;
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
@@ -31,8 +31,43 @@ axiosClient.interceptors.request.use(
axiosClient.interceptors.response.use( axiosClient.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
if (error?.response?.status === 401 && typeof window !== 'undefined') { const originalRequest = error?.config;
await signOut({ callbackUrl: '/login' }); 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); return Promise.reject(error);

View File

@@ -0,0 +1,16 @@
import { authService } from '@/services/api/auth.service';
import { useAppStore } from '@/store/app.store';
import type { UserProfile } from '@/types';
export async function initializeAuthenticatedApp() {
const [{ user, tenant }, permissionsResponse] = await Promise.all([
authService.me(),
authService.permissions(),
]);
useAppStore.getState().setUserContext({
user: user as UserProfile,
tenant,
permissions: permissionsResponse.granted_permissions ?? [],
});
}

View File

@@ -1,16 +0,0 @@
import { getSession } from 'next-auth/react';
export const tokenService = {
getAccessToken: async (): Promise<string | null> => {
if (typeof window === 'undefined') return null;
const session = await getSession();
return session?.accessToken ?? null;
},
getRefreshToken: async (): Promise<string | null> => {
if (typeof window === 'undefined') return null;
const session = await getSession();
return session?.refreshToken ?? null;
},
};

48
src/store/app.store.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { TenantInfo, UserProfile } from '@/types';
import { create } from 'zustand';
export type AppLoadStatus = 'idle' | 'loading' | 'loaded' | 'error';
interface AppState {
user: UserProfile | null;
tenant: TenantInfo | null;
permissions: string[];
loadStatus: AppLoadStatus;
isInitialized: boolean;
setLoading: () => void;
setLoaded: () => void;
setLoadError: () => void;
setUserContext: (payload: {
user: UserProfile;
tenant?: TenantInfo | null;
permissions?: string[];
}) => void;
clear: () => void;
}
export const useAppStore = create<AppState>()((set) => ({
user: null,
tenant: null,
permissions: [],
loadStatus: 'idle',
isInitialized: false,
setLoading: () => set({ loadStatus: 'loading', isInitialized: false }),
setLoaded: () => set({ loadStatus: 'loaded', isInitialized: true }),
setLoadError: () => set({ loadStatus: 'error', isInitialized: true }),
setUserContext: ({ user, tenant = null, permissions = [] }) =>
set({
user,
tenant,
permissions,
loadStatus: 'loaded',
isInitialized: true,
}),
clear: () =>
set({
user: null,
tenant: null,
permissions: [],
loadStatus: 'idle',
isInitialized: false,
}),
}));

View File

@@ -1,40 +1,40 @@
import type { UserProfile } from '@/types';
import { create } from 'zustand'; import { 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 { interface AuthState {
user: UserProfile | null;
accessToken: string | null; accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
setAuth: (user: UserProfile, accessToken?: string | null, refreshToken?: string | null) => void; setAccessToken: (accessToken: string) => void;
logout: () => void; logout: () => void;
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()((set) => ({
persist( accessToken: getStoredAccessToken(),
(set) => ({ isAuthenticated: !!getStoredAccessToken(),
user: null, 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, accessToken: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) => });
set({ },
user, }));
accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null,
isAuthenticated: true,
}),
logout: () =>
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage',
},
),
);

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

@@ -1,29 +0,0 @@
import 'next-auth';
import 'next-auth/jwt';
declare module 'next-auth' {
interface Session {
accessToken?: string;
refreshToken?: string;
user: {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
interface User {
id: string;
accessToken?: string;
refreshToken?: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string;
accessToken?: string;
refreshToken?: string;
}
}

View File

@@ -1,16 +1,55 @@
export type AuthUser = { export type AuthUser = {
id?: string | number; id?: string | number;
username?: string;
first_name?: string;
last_name?: string;
name?: string | null; name?: string | null;
email?: string | null; email?: string | null;
role?: 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 = { export type UserProfile = {
id?: string; id?: string | number;
username?: string;
first_name?: string;
last_name?: string;
name?: string | null; name?: string | null;
email?: string | null; email?: string | null;
image?: string | null; image?: string | null;
role?: 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 = { export type LoginPayload = {
@@ -20,12 +59,6 @@ export type LoginPayload = {
}; };
export type AuthResponseData = { export type AuthResponseData = {
id?: string | number; access_token?: string;
userId?: string | number; token_type?: string;
name?: string | null;
email?: string | null;
token?: string;
accessToken?: string;
refreshToken?: string;
user?: AuthUser;
}; };