feat: add auth setup and login flow

This commit is contained in:
2026-06-15 15:56:35 +05:30
parent 134e2d1bb8
commit 9de65d0ce6
18 changed files with 664 additions and 19 deletions

204
package-lock.json generated
View File

@@ -27,14 +27,17 @@
"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",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
@@ -1297,6 +1300,15 @@
"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",
@@ -4875,6 +4887,15 @@
"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",
@@ -7065,6 +7086,15 @@
"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",
@@ -7871,6 +7901,38 @@
} }
} }
}, },
"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",
@@ -7961,6 +8023,12 @@
"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",
@@ -7970,6 +8038,15 @@
"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",
@@ -8083,6 +8160,15 @@
"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",
@@ -8099,6 +8185,39 @@
"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",
@@ -8272,6 +8391,28 @@
"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",
@@ -8311,6 +8452,12 @@
"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",
@@ -8578,6 +8725,22 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.79.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
"integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -9914,6 +10077,16 @@
"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",
@@ -10166,6 +10339,35 @@
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
}, },
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
},
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.10", "version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",

View File

@@ -39,14 +39,17 @@
"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",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.79.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",

View File

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

View File

@@ -0,0 +1,6 @@
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,6 +2,8 @@ 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 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';
@@ -34,11 +36,15 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<div className="relative min-h-screen isolate"> <QueryProvider>
<BackgroundGradient /> <AuthProvider>
{children} <div className="relative min-h-screen isolate">
</div> <BackgroundGradient />
<Toaster position="top-center" /> {children}
</div>
<Toaster position="top-center" />
</AuthProvider>
</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

63
src/hooks/useLoginForm.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useAuthStore } from '@/store/auth.store';
import type { LoginPayload, UserProfile } 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 {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginPayload>({
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginPayload) => {
try {
const callbackUrl =
new URLSearchParams(window.location.search).get('callbackUrl') ?? ROUTES.DASHBOARD;
const result = await signIn('credentials', {
email: data.email,
password: data.password,
redirect: false,
callbackUrl,
});
if (result?.error) {
toast.error(result.error);
return;
}
const session = await getSession();
if (session?.user) {
setAuth(
session.user as UserProfile,
session.accessToken ?? null,
session.refreshToken ?? null,
);
}
toast.success('Login successful');
router.push(result?.url ?? callbackUrl);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
}
};
return {
register,
handleSubmit: handleSubmit(onSubmit),
errors,
isLoading: isSubmitting,
};
};

View File

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

38
src/proxy.ts Normal file
View File

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,9 @@
import type { AuthResponseData, LoginPayload } from '@/types';
import { axiosAuth } from '../axios/axios';
export const authService = {
login: async (payload: LoginPayload): Promise<AuthResponseData> => {
const response = await axiosAuth.post<AuthResponseData>('/auth/login', payload);
return response.data;
},
};

View File

@@ -1,5 +1,6 @@
export * from './project.service'; export * from './project.service';
export * from './package.service'; export * from './package.service';
export * from './auth.service';
export { chainageService } from './chainage.service'; export { chainageService } from './chainage.service';
export * from './video.service'; export * from './video.service';
export * from './detection.service'; export * from './detection.service';

View File

@@ -1,25 +1,26 @@
import { ENV_CONSTANT } from '@/constants/secrect.constant'; import { ENV_CONSTANT } from '@/constants/secrect.constant';
import { tokenService } from '@/services/token.service';
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;
const axiosClient = axios.create({ const axiosClient = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
headers: { headers: {
// "Content-Type": "application/json", 'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true', 'ngrok-skip-browser-warning': 'true',
}, },
}); });
// Add request interceptor to inject access token
axiosClient.interceptors.request.use( axiosClient.interceptors.request.use(
(config) => { async (config) => {
if (typeof window !== 'undefined') { const token = await tokenService.getAccessToken();
const token = localStorage.getItem('token');
if (token && config.headers) { if (token && config.headers) {
config.headers['Authorization'] = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
}
} }
return config; return config;
}, },
(error) => { (error) => {
@@ -27,18 +28,23 @@ axiosClient.interceptors.request.use(
}, },
); );
// Basic response interceptor
axiosClient.interceptors.response.use( axiosClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// Standard error handling can be added here later if (error?.response?.status === 401 && typeof window !== 'undefined') {
await signOut({ callbackUrl: '/login' });
}
return Promise.reject(error); return Promise.reject(error);
}, },
); );
export const axiosAuth = axios.create({ export const axiosAuth = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true',
},
}); });
export default axiosClient; export default axiosClient;

View File

@@ -0,0 +1,16 @@
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;
},
};

40
src/store/auth.store.ts Normal file
View File

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

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

@@ -0,0 +1,29 @@
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;
}
}

31
src/types/auth.type.ts Normal file
View File

@@ -0,0 +1,31 @@
export type AuthUser = {
id?: string | number;
name?: string | null;
email?: string | null;
role?: string | null;
};
export type UserProfile = {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
role?: string | null;
};
export type LoginPayload = {
email: string;
password: string;
rememberMe?: boolean;
};
export type AuthResponseData = {
id?: string | number;
userId?: string | number;
name?: string | null;
email?: string | null;
token?: string;
accessToken?: string;
refreshToken?: string;
user?: AuthUser;
};

View File

@@ -6,3 +6,4 @@ export * from './video';
export * from './detection'; export * from './detection';
export * from './analysis'; export * from './analysis';
export * from './session'; export * from './session';
export * from './auth.type';

View File

@@ -1,4 +1,5 @@
export const ROUTES = { export const ROUTES = {
LOGIN: '/login',
DASHBOARD: '/dashboard', DASHBOARD: '/dashboard',
PROJECT: '/project', PROJECT: '/project',
PACKAGE: '/package', PACKAGE: '/package',