Compare commits
	
		
			2 Commits
		
	
	
		
			bf3d9fc1d0
			...
			a3d2e24861
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a3d2e24861 | ||
|   | 2215633fb9 | 
| @@ -81,7 +81,7 @@ services: | |||||||
|       DB_USER: "center" |       DB_USER: "center" | ||||||
|       DB_PASSWORD: "Center#2025" |       DB_PASSWORD: "Center#2025" | ||||||
|       DB_NAME: "dms" |       DB_NAME: "dms" | ||||||
|       JWT_SECRET: "8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e" |       JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|       JWT_EXPIRES_IN: "12h" |       JWT_EXPIRES_IN: "12h" | ||||||
|       PASSWORD_SALT_ROUNDS: "10" |       PASSWORD_SALT_ROUNDS: "10" | ||||||
|       FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" |       FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" | ||||||
| @@ -130,10 +130,11 @@ services: | |||||||
|       CHOKIDAR_USEPOLLING: "1" |       CHOKIDAR_USEPOLLING: "1" | ||||||
|       WATCHPACK_POLLING: "true" |       WATCHPACK_POLLING: "true" | ||||||
|       NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work" |       NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work" | ||||||
|  |       NEXT_PUBLIC_AUTH_MODE: "cookie" | ||||||
|       NEXT_PUBLIC_DEBUG_AUTH: "1" |       NEXT_PUBLIC_DEBUG_AUTH: "1" | ||||||
|       NEXT_TELEMETRY_DISABLED: "1" |       NEXT_TELEMETRY_DISABLED: "1" | ||||||
|       JWT_ACCESS_SECRET: "change-this-access-secret" |       JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||||
|       JWT_REFRESH_SECRET: "change-this-refresh-secret" |       JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" | ||||||
|     expose: |     expose: | ||||||
|       - "3000" |       - "3000" | ||||||
|     networks: [dmsnet] |     networks: [dmsnet] | ||||||
|   | |||||||
| @@ -1,54 +1,34 @@ | |||||||
| // File: frontend/app/(auth)/login/page.jsx | // File: frontend/app/(auth)/login/page.jsx | ||||||
|  |  | ||||||
| "use client"; | "use client"; | ||||||
|  |  | ||||||
| // ✅ ปรับให้ตรง backend: ใช้ Bearer token (ไม่ใช้ cookie) |  | ||||||
| // - เรียก POST /api/auth/login → รับ { token, refresh_token, user } |  | ||||||
| // - เก็บ token/refresh_token ใน localStorage (หรือ sessionStorage ถ้าไม่ติ๊กจำไว้) |  | ||||||
| // - ไม่ใช้ credentials: "include" อีกต่อไป |  | ||||||
| // - เอา RootLayout/metadata ออก เพราะไฟล์เพจเป็น client component |  | ||||||
| // - เพิ่มการอ่าน NEXT_PUBLIC_API_BASE และ error handling ให้ตรงกับ backend |  | ||||||
| // - เพิ่มโหมดดีบัก เปิดด้วย NEXT_PUBLIC_DEBUG_AUTH=1 |  | ||||||
|  |  | ||||||
| import { useState, useMemo, Suspense } from "react"; | import { useState, useMemo, Suspense } from "react"; | ||||||
| import { useSearchParams, useRouter } from "next/navigation"; | import { useSearchParams, useRouter } from "next/navigation"; | ||||||
| import { | import { | ||||||
|   Card, |   Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, | ||||||
|   CardHeader, |  | ||||||
|   CardTitle, |  | ||||||
|   CardDescription, |  | ||||||
|   CardContent, |  | ||||||
|   CardFooter, |  | ||||||
| } from "@/components/ui/card"; | } from "@/components/ui/card"; | ||||||
| import { Label } from "@/components/ui/label"; | import { Label } from "@/components/ui/label"; | ||||||
| import { Input } from "@/components/ui/input"; | import { Input } from "@/components/ui/input"; | ||||||
| import { Button } from "@/components/ui/button"; | import { Button } from "@/components/ui/button"; | ||||||
| import { Alert, AlertDescription } from "@/components/ui/alert"; | import { Alert, AlertDescription } from "@/components/ui/alert"; | ||||||
|  |  | ||||||
| const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || ""; | const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, ""); | ||||||
| const DEBUG = | const DEBUG = | ||||||
|   String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" && |   String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" && | ||||||
|   process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" && |   process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" && | ||||||
|   process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false"; |   process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false"; | ||||||
|  |  | ||||||
| function dlog(...args) { | function dlog(...args) { | ||||||
|   if (DEBUG && typeof window !== "undefined") { |   if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args); | ||||||
|     console.debug("[login]", ...args); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function LoginForm() { | function LoginForm() { | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const searchParams = useSearchParams(); |   const searchParams = useSearchParams(); | ||||||
|   const nextPath = useMemo( |   const nextPath = useMemo(() => searchParams.get("next") || "/dashboard", [searchParams]); | ||||||
|     () => searchParams.get("next") || "/dashboard", |  | ||||||
|     [searchParams] |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   const [username, setUsername] = useState(""); |   const [username, setUsername] = useState(""); | ||||||
|   const [password, setPassword] = useState(""); |   const [password, setPassword] = useState(""); | ||||||
|   const [showPw, setShowPw] = useState(false); |   const [showPw, setShowPw] = useState(false); | ||||||
|   const [remember, setRemember] = useState(false); |  | ||||||
|   const [submitting, setSubmitting] = useState(false); |   const [submitting, setSubmitting] = useState(false); | ||||||
|   const [err, setErr] = useState(""); |   const [err, setErr] = useState(""); | ||||||
|  |  | ||||||
| @@ -63,69 +43,37 @@ function LoginForm() { | |||||||
|  |  | ||||||
|     try { |     try { | ||||||
|       setSubmitting(true); |       setSubmitting(true); | ||||||
|  |       dlog("API_BASE =", API_BASE || "(empty → relative path)"); dlog("nextPath =", nextPath); | ||||||
|       // ── DEBUG: ค่าเบื้องต้น |  | ||||||
|       dlog("API_BASE =", API_BASE || "(empty → จะเรียก path relative)"); |  | ||||||
|       dlog("nextPath =", nextPath); |  | ||||||
|       dlog("remember =", remember); |  | ||||||
|       dlog("payload =", { username: "[hidden]", password: "[hidden]" }); |  | ||||||
|  |  | ||||||
|       const res = await fetch(`${API_BASE}/api/auth/login`, { |       const res = await fetch(`${API_BASE}/api/auth/login`, { | ||||||
|         method: "POST", |         method: "POST", | ||||||
|         headers: { "Content-Type": "application/json" }, |         headers: { "Content-Type": "application/json" }, | ||||||
|         body: JSON.stringify({ username, password }), |         credentials: "include",         // << ใช้คุกกี้ | ||||||
|         cache: "no-store", |         cache: "no-store", | ||||||
|  |         body: JSON.stringify({ username, password }), | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       dlog("response.status =", res.status); |       dlog("status =", res.status, "ctype =", res.headers.get("content-type")); | ||||||
|       dlog("response.headers.content-type =", res.headers.get("content-type")); |  | ||||||
|  |  | ||||||
|       let data = {}; |       let data = {}; | ||||||
|       try { |       try { data = await res.json(); } catch {} | ||||||
|         data = await res.json(); |  | ||||||
|       } catch (e) { |  | ||||||
|         dlog("response.json() error =", e); |  | ||||||
|       } |  | ||||||
|       dlog("response.body =", data); |  | ||||||
|  |  | ||||||
|       if (!res.ok) { |       if (!res.ok) { | ||||||
|         const msg = |         const msg = | ||||||
|           data?.error === "INVALID_CREDENTIALS" |           data?.error === "INVALID_CREDENTIALS" | ||||||
|             ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง" |             ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง" | ||||||
|             : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`; |             : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`; | ||||||
|         dlog("login FAILED →", msg); |  | ||||||
|         setErr(msg); |         setErr(msg); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (!data?.token) { |       // คุกกี้ (HttpOnly) ถูกตั้งด้วย Set-Cookie จาก backend แล้ว | ||||||
|         dlog("login FAILED → data.token not found"); |       dlog("login ok → redirect", nextPath); | ||||||
|         setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)"); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // ✅ เก็บ token ตามโหมดจำไว้/ไม่จำ |  | ||||||
|       const storage = remember ? window.localStorage : window.sessionStorage; |  | ||||||
|       storage.setItem("dms.token", data.token); |  | ||||||
|       storage.setItem("dms.refresh_token", data.refresh_token); |  | ||||||
|       storage.setItem("dms.user", JSON.stringify(data.user || {})); |  | ||||||
|       dlog("token stored in", remember ? "localStorage" : "sessionStorage"); |  | ||||||
|  |  | ||||||
|       // (ออปชัน) เผยแพร่ event ให้แท็บอื่นทราบ |  | ||||||
|       try { |  | ||||||
|         window.dispatchEvent( |  | ||||||
|           new StorageEvent("storage", { key: "dms.auth", newValue: "login" }) |  | ||||||
|         ); |  | ||||||
|       } catch {} |  | ||||||
|  |  | ||||||
|       dlog("navigating →", nextPath); |  | ||||||
|       router.replace(nextPath); |       router.replace(nextPath); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       dlog("exception =", e); |       dlog("exception =", e); | ||||||
|       setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่"); |       setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่"); | ||||||
|     } finally { |     } finally { | ||||||
|       setSubmitting(false); |       setSubmitting(false); | ||||||
|       dlog("done"); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -133,32 +81,22 @@ function LoginForm() { | |||||||
|     <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4"> |     <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4"> | ||||||
|       <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur"> |       <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur"> | ||||||
|         <CardHeader className="space-y-1"> |         <CardHeader className="space-y-1"> | ||||||
|           <CardTitle className="text-2xl font-bold text-sky-800"> |           <CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle> | ||||||
|             เข้าสู่ระบบ |           <CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription> | ||||||
|           </CardTitle> |  | ||||||
|           <CardDescription className="text-sky-700"> |  | ||||||
|             Document Management System • LCBP3 |  | ||||||
|           </CardDescription> |  | ||||||
|         </CardHeader> |         </CardHeader> | ||||||
|  |  | ||||||
|         <CardContent> |         <CardContent> | ||||||
|           {err ? ( |           {err ? ( | ||||||
|             <Alert className="mb-4"> |             <Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert> | ||||||
|               <AlertDescription>{err}</AlertDescription> |  | ||||||
|             </Alert> |  | ||||||
|           ) : null} |           ) : null} | ||||||
|  |  | ||||||
|           <form onSubmit={onSubmit} className="grid gap-4"> |           <form onSubmit={onSubmit} className="grid gap-4"> | ||||||
|             <div className="grid gap-2"> |             <div className="grid gap-2"> | ||||||
|               <Label htmlFor="username">ชื่อผู้ใช้</Label> |               <Label htmlFor="username">ชื่อผู้ใช้</Label> | ||||||
|               <Input |               <Input | ||||||
|                 id="username" |                 id="username" autoFocus autoComplete="username" | ||||||
|                 autoFocus |                 value={username} onChange={(e) => setUsername(e.target.value)} | ||||||
|                 autoComplete="username" |                 placeholder="เช่น superadmin" disabled={submitting} | ||||||
|                 value={username} |  | ||||||
|                 onChange={(e) => setUsername(e.target.value)} |  | ||||||
|                 placeholder="เช่น superadmin" |  | ||||||
|                 disabled={submitting} |  | ||||||
|               /> |               /> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
| @@ -166,59 +104,22 @@ function LoginForm() { | |||||||
|               <Label htmlFor="password">รหัสผ่าน</Label> |               <Label htmlFor="password">รหัสผ่าน</Label> | ||||||
|               <div className="relative"> |               <div className="relative"> | ||||||
|                 <Input |                 <Input | ||||||
|                   id="password" |                   id="password" type={showPw ? "text" : "password"} autoComplete="current-password" | ||||||
|                   type={showPw ? "text" : "password"} |                   value={password} onChange={(e) => setPassword(e.target.value)} | ||||||
|                   autoComplete="current-password" |                   placeholder="••••••••" disabled={submitting} className="pr-10" | ||||||
|                   value={password} |  | ||||||
|                   onChange={(e) => setPassword(e.target.value)} |  | ||||||
|                   placeholder="••••••••" |  | ||||||
|                   disabled={submitting} |  | ||||||
|                   className="pr-10" |  | ||||||
|                 /> |                 /> | ||||||
|                 <button |                 <button | ||||||
|                   type="button" |                   type="button" onClick={() => setShowPw((v) => !v)} | ||||||
|                   onClick={() => setShowPw((v) => !v)} |  | ||||||
|                   className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50" |                   className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50" | ||||||
|                   aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} |                   aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting} | ||||||
|                   disabled={submitting} |  | ||||||
|                 > |                 > | ||||||
|                   {showPw ? "Hide" : "Show"} |                   {showPw ? "Hide" : "Show"} | ||||||
|                 </button> |                 </button> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div className="flex items-center justify-between pt-1"> |             <Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800"> | ||||||
|               <label className="inline-flex items-center gap-2 text-sm text-slate-600"> |               {submitting ? <span className="inline-flex items-center gap-2"><Spinner /> กำลังเข้าสู่ระบบ…</span> : "เข้าสู่ระบบ"} | ||||||
|                 <input |  | ||||||
|                   type="checkbox" |  | ||||||
|                   className="size-4 accent-sky-700" |  | ||||||
|                   checked={remember} |  | ||||||
|                   onChange={(e) => setRemember(e.target.checked)} |  | ||||||
|                   disabled={submitting} |  | ||||||
|                 /> |  | ||||||
|                 จดจำฉันไว้ในเครื่องนี้ |  | ||||||
|               </label> |  | ||||||
|  |  | ||||||
|               <a |  | ||||||
|                 href="/forgot-password" |  | ||||||
|                 className="text-sm text-sky-700 hover:text-sky-900 hover:underline" |  | ||||||
|               > |  | ||||||
|                 ลืมรหัสผ่าน? |  | ||||||
|               </a> |  | ||||||
|             </div> |  | ||||||
|  |  | ||||||
|             <Button |  | ||||||
|               type="submit" |  | ||||||
|               disabled={submitting} |  | ||||||
|               className="mt-2 bg-sky-700 hover:bg-sky-800" |  | ||||||
|             > |  | ||||||
|               {submitting ? ( |  | ||||||
|                 <span className="inline-flex items-center gap-2"> |  | ||||||
|                   <Spinner /> กำลังเข้าสู่ระบบ… |  | ||||||
|                 </span> |  | ||||||
|               ) : ( |  | ||||||
|                 "เข้าสู่ระบบ" |  | ||||||
|               )} |  | ||||||
|             </Button> |             </Button> | ||||||
|  |  | ||||||
|             {DEBUG ? ( |             {DEBUG ? ( | ||||||
| @@ -245,18 +146,13 @@ export default function LoginPage() { | |||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| /** Loading skeleton */ |  | ||||||
| function LoginPageSkeleton() { | function LoginPageSkeleton() { | ||||||
|   return ( |   return ( | ||||||
|     <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4"> |     <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4"> | ||||||
|       <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur"> |       <Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur"> | ||||||
|         <CardHeader className="space-y-1"> |         <CardHeader className="space-y-1"> | ||||||
|           <CardTitle className="text-2xl font-bold text-sky-800"> |           <CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle> | ||||||
|             เข้าสู่ระบบ |           <CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription> | ||||||
|           </CardTitle> |  | ||||||
|           <CardDescription className="text-sky-700"> |  | ||||||
|             Document Management System • LCBP3 |  | ||||||
|           </CardDescription> |  | ||||||
|         </CardHeader> |         </CardHeader> | ||||||
|         <CardContent> |         <CardContent> | ||||||
|           <div className="grid gap-4 animate-pulse"> |           <div className="grid gap-4 animate-pulse"> | ||||||
| @@ -270,28 +166,11 @@ function LoginPageSkeleton() { | |||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| /** Spinner แบบไม่พึ่งไลบรารีเสริม */ |  | ||||||
| function Spinner() { | function Spinner() { | ||||||
|   return ( |   return ( | ||||||
|     <svg |     <svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"> | ||||||
|       className="animate-spin size-4" |       <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> | ||||||
|       viewBox="0 0 24 24" |       <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> | ||||||
|       fill="none" |  | ||||||
|       aria-hidden="true" |  | ||||||
|     > |  | ||||||
|       <circle |  | ||||||
|         className="opacity-25" |  | ||||||
|         cx="12" |  | ||||||
|         cy="12" |  | ||||||
|         r="10" |  | ||||||
|         stroke="currentColor" |  | ||||||
|         strokeWidth="4" |  | ||||||
|       /> |  | ||||||
|       <path |  | ||||||
|         className="opacity-75" |  | ||||||
|         fill="currentColor" |  | ||||||
|         d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" |  | ||||||
|       /> |  | ||||||
|     </svg> |     </svg> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								frontend/app/_auth/AuthDriver.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/app/_auth/AuthDriver.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | //File: frontend/app/_auth/AuthDriver.js | ||||||
|  | import cookieDriver from './drivers/cookieDriver'; | ||||||
|  | import bearerDriver from './drivers/bearerDriver'; | ||||||
|  |  | ||||||
|  | const mode = (process.env.NEXT_PUBLIC_AUTH_MODE || 'cookie').toLowerCase(); | ||||||
|  | const driver = mode === 'bearer' ? bearerDriver : cookieDriver; | ||||||
|  | export default driver; | ||||||
							
								
								
									
										69
									
								
								frontend/app/_auth/drivers/bearerDriver.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								frontend/app/_auth/drivers/bearerDriver.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | //File: frontend/app/_auth/drivers/cookieDriver.js | ||||||
|  | const API = process.env.NEXT_PUBLIC_API_BASE; | ||||||
|  |  | ||||||
|  | let accessToken = null; | ||||||
|  | let refreshToken = null; | ||||||
|  |  | ||||||
|  | function load() { | ||||||
|  |   if (typeof window === 'undefined') return; | ||||||
|  |   accessToken  = localStorage.getItem('access_token'); | ||||||
|  |   refreshToken = localStorage.getItem('refresh_token'); | ||||||
|  | } | ||||||
|  | function save(a, r) { | ||||||
|  |   if (typeof window === 'undefined') return; | ||||||
|  |   if (a) { accessToken = a; localStorage.setItem('access_token', a); } | ||||||
|  |   if (r) { refreshToken = r; localStorage.setItem('refresh_token', r); } | ||||||
|  | } | ||||||
|  | function clear() { | ||||||
|  |   if (typeof window === 'undefined') return; | ||||||
|  |   accessToken = null; refreshToken = null; | ||||||
|  |   localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   mode: 'bearer', | ||||||
|  |   async login({ username, password }) { | ||||||
|  |     const r = await fetch(`${API}/api/auth/login`, { | ||||||
|  |       method: 'POST', | ||||||
|  |       headers: { 'Content-Type': 'application/json' }, | ||||||
|  |       body: JSON.stringify({ username, password }) | ||||||
|  |     }); | ||||||
|  |     if (!r.ok) throw new Error(`Login failed: ${r.status}`); | ||||||
|  |     const j = await r.json(); | ||||||
|  |     if (!j?.access_token) throw new Error('No access_token'); | ||||||
|  |     save(j.access_token, j.refresh_token); | ||||||
|  |     return true; | ||||||
|  |   }, | ||||||
|  |   async me() { | ||||||
|  |     load(); | ||||||
|  |     const headers = this.authHeaders(); | ||||||
|  |     const r = await fetch(`${API}/api/auth/me`, { headers }); | ||||||
|  |     if (r.status === 401 && await this.refresh()) { | ||||||
|  |       const r2 = await fetch(`${API}/api/auth/me`, { headers: this.authHeaders() }); | ||||||
|  |       if (!r2.ok) return { ok: false }; | ||||||
|  |       return r2.json(); | ||||||
|  |     } | ||||||
|  |     if (!r.ok) return { ok: false }; | ||||||
|  |     return r.json(); | ||||||
|  |   }, | ||||||
|  |   async refresh() { | ||||||
|  |     load(); | ||||||
|  |     if (!refreshToken) return false; | ||||||
|  |     const r = await fetch(`${API}/api/auth/refresh`, { | ||||||
|  |       method: 'POST', | ||||||
|  |       headers: { 'Content-Type': 'application/json' }, | ||||||
|  |       body: JSON.stringify({ refresh_token: refreshToken }) | ||||||
|  |     }); | ||||||
|  |     if (!r.ok) { clear(); return false; } | ||||||
|  |     const j = await r.json(); | ||||||
|  |     if (!j?.access_token) return false; | ||||||
|  |     save(j.access_token, j.refresh_token ?? refreshToken); | ||||||
|  |     return true; | ||||||
|  |   }, | ||||||
|  |   authHeaders(h = {}) { | ||||||
|  |     load(); | ||||||
|  |     return accessToken ? { ...h, Authorization: `Bearer ${accessToken}` } : h; | ||||||
|  |   }, | ||||||
|  |   credentials() { return 'omit'; }, | ||||||
|  |   async logout() { clear(); } | ||||||
|  | }; | ||||||
							
								
								
									
										34
									
								
								frontend/app/_auth/drivers/cookieDriver.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								frontend/app/_auth/drivers/cookieDriver.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | //File: frontend/app/_auth/drivers/cookieDriver.js | ||||||
|  | const API = process.env.NEXT_PUBLIC_API_BASE; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   mode: 'cookie', | ||||||
|  |   async login({ username, password }) { | ||||||
|  |     const r = await fetch(`${API}/api/auth/login`, { | ||||||
|  |       method: 'POST', | ||||||
|  |       headers: { 'Content-Type': 'application/json' }, | ||||||
|  |       credentials: 'include',         // สำคัญสำหรับคุกกี้ | ||||||
|  |       body: JSON.stringify({ username, password }) | ||||||
|  |     }); | ||||||
|  |     if (!r.ok) throw new Error(`Login failed: ${r.status}`); | ||||||
|  |     return true; // คุกกี้ถูกตั้งแล้วโดย backend | ||||||
|  |   }, | ||||||
|  |   async me() { | ||||||
|  |     const r = await fetch(`${API}/api/auth/me`, { credentials: 'include' }); | ||||||
|  |     if (!r.ok) return { ok: false }; | ||||||
|  |     return r.json(); | ||||||
|  |   }, | ||||||
|  |   async refresh() { | ||||||
|  |     // ถ้า backend ออก access ใหม่ผ่าน refresh (ในคุกกี้) ก็เรียกได้ | ||||||
|  |     const r = await fetch(`${API}/api/auth/refresh`, { | ||||||
|  |       method: 'POST', | ||||||
|  |       credentials: 'include' | ||||||
|  |     }); | ||||||
|  |     return r.ok; | ||||||
|  |   }, | ||||||
|  |   authHeaders(h = {}) { return h; }, // คุกกี้ไม่ต้องใส่ Authorization | ||||||
|  |   credentials() { return 'include'; }, | ||||||
|  |   async logout() { | ||||||
|  |     await fetch(`${API}/api/auth/logout`, { method: 'POST', credentials: 'include' }); | ||||||
|  |   } | ||||||
|  | }; | ||||||
							
								
								
									
										23
									
								
								frontend/app/_auth/useAuthGuard.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/app/_auth/useAuthGuard.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | //File: frontend/app/_auth/useAuthGuard.jsx | ||||||
|  | 'use client'; | ||||||
|  | import { useEffect, useState } from 'react'; | ||||||
|  | import { usePathname, useRouter } from 'next/navigation'; | ||||||
|  | import Auth from './AuthDriver'; | ||||||
|  |  | ||||||
|  | export default function UseAuthGuard({ children }) { | ||||||
|  |   const [ok, setOk] = useState(false); | ||||||
|  |   const router = useRouter(); | ||||||
|  |   const pathname = usePathname(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     let alive = true; | ||||||
|  |     (async () => { | ||||||
|  |       const me = await Auth.me().catch(() => ({ ok: false })); | ||||||
|  |       if (alive && me?.ok) setOk(true); | ||||||
|  |       else if (alive) router.replace(`/login?next=${encodeURIComponent(pathname || '/')}`); | ||||||
|  |     })(); | ||||||
|  |     return () => { alive = false; }; | ||||||
|  |   }, [pathname, router]); | ||||||
|  |  | ||||||
|  |   return ok ? <>{children}</> : null; | ||||||
|  | } | ||||||
| @@ -1,69 +1,112 @@ | |||||||
| // app/layout.jsx | // File: frontend/app/(protected)/layout.jsx | ||||||
| import "./globals.css"; | import Link from "next/link"; | ||||||
| import { Inter } from "next/font/google"; | import { redirect } from "next/navigation"; | ||||||
|  | import { cookies, headers } from "next/headers"; | ||||||
|  | // ถ้ามี lib rbac เดิมอยู่ให้ใช้ต่อได้ | ||||||
|  | import { can } from "@/lib/rbac"; | ||||||
|  |  | ||||||
| const inter = Inter({ subsets: ["latin"] }); | // แก้ title ให้ถูกสะกด | ||||||
|  | export const metadata = { title: "DMS | Protected" }; | ||||||
|  |  | ||||||
| export const metadata = { | const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, ""); | ||||||
|   title: "DMS", |  | ||||||
|   description: "Document Management System", | async function fetchSessionFromAPI() { | ||||||
| }; |   // ดึงคุกกี้จริงจากฝั่งเซิร์ฟเวอร์ แล้วส่งต่อให้ backend | ||||||
|  |   const cookieHeader = cookies().toString(); // serialize ทั้งชุด | ||||||
|  |   const hostHdr = headers().get("host"); | ||||||
|  |   const protoHdr = headers().get("x-forwarded-proto") || "https"; | ||||||
|  |  | ||||||
|  |   const res = await fetch(`${API_BASE}/api/auth/me`, { | ||||||
|  |     method: "GET", | ||||||
|  |     headers: { | ||||||
|  |       Cookie: cookieHeader, | ||||||
|  |       // เผื่อ backend ตรวจ origin/proto/host | ||||||
|  |       "X-Forwarded-Host": hostHdr || "", | ||||||
|  |       "X-Forwarded-Proto": protoHdr, | ||||||
|  |       Accept: "application/json", | ||||||
|  |     }, | ||||||
|  |     // server component ไม่ต้องใช้ credentials | ||||||
|  |     cache: "no-store", | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!res.ok) return null; | ||||||
|  |   try { | ||||||
|  |     const data = await res.json(); | ||||||
|  |     return data?.ok ? data : null; | ||||||
|  |   } catch { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default async function ProtectedLayout({ children }) { | ||||||
|  |   const session = await fetchSessionFromAPI(); | ||||||
|  |   if (!session) { | ||||||
|  |     // พยายามส่ง next path กลับไปที่ /login | ||||||
|  |     redirect("/login?next=/dashboard"); | ||||||
|  |   } | ||||||
|  |   const { user } = session; | ||||||
|  |  | ||||||
| export default function RootLayout({ children }) { |  | ||||||
|   return ( |   return ( | ||||||
|     <html lang="en" suppressHydrationWarning> |     <section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl"> | ||||||
|       <body |       <aside className="col-span-12 lg:col-span-3 xl:col-span-3"> | ||||||
|         className={`${inter.className} min-h-screen bg-background text-foreground`} |         <div className="p-4 border rounded-3xl bg-white/70"> | ||||||
|       > |           <div className="mb-3 text-sm">RBAC: <b>{user.role}</b></div> | ||||||
|         <div className="flex min-h-screen"> |  | ||||||
|           {/* Sidebar */} |  | ||||||
|           <aside className="w-64 bg-primary text-primary-foreground p-4"> |  | ||||||
|             <h1 className="text-xl font-bold mb-6">📂 DMS</h1> |  | ||||||
|             <nav className="space-y-2"> |  | ||||||
|               <a |  | ||||||
|                 href="/dashboard" |  | ||||||
|                 className="block px-3 py-2 rounded hover:bg-accent" |  | ||||||
|               > |  | ||||||
|                 Dashboard |  | ||||||
|               </a> |  | ||||||
|               <a |  | ||||||
|                 href="/correspondences" |  | ||||||
|                 className="block px-3 py-2 rounded hover:bg-accent" |  | ||||||
|               > |  | ||||||
|                 Correspondences |  | ||||||
|               </a> |  | ||||||
|               <a |  | ||||||
|                 href="/users" |  | ||||||
|                 className="block px-3 py-2 rounded hover:bg-accent" |  | ||||||
|               > |  | ||||||
|                 Users |  | ||||||
|               </a> |  | ||||||
|               <a |  | ||||||
|                 href="/health" |  | ||||||
|                 className="block px-3 py-2 rounded hover:bg-accent" |  | ||||||
|               > |  | ||||||
|                 Health |  | ||||||
|               </a> |  | ||||||
|             </nav> |  | ||||||
|           </aside> |  | ||||||
|  |  | ||||||
|           {/* Main content */} |           <nav className="space-y-2"> | ||||||
|           <main className="flex-1 flex flex-col"> |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/dashboard">แดชบอร์ด</Link> | ||||||
|             {/* Top Navbar */} |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/drawings">Drawings</Link> | ||||||
|             <header className="h-14 bg-secondary text-secondary-foreground flex items-center justify-between px-6 shadow-sm"> |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/rfas">RFAs</Link> | ||||||
|               <span className="font-medium">Laem Chabang Port Phase 3</span> |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/transmittals">Transmittals</Link> | ||||||
|               <div className="flex items-center space-x-4"> |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/correspondences">Correspondences</Link> | ||||||
|                 <button className="px-3 py-1 rounded bg-accent text-accent-foreground hover:opacity-90"> |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/contracts-volumes">Contracts & Volumes</Link> | ||||||
|                   Quick Action |             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/reports">Reports</Link> | ||||||
|                 </button> |  | ||||||
|                 <span className="text-sm">superadmin</span> |  | ||||||
|               </div> |  | ||||||
|             </header> |  | ||||||
|  |  | ||||||
|             <section className="p-6 flex-1">{children}</section> |             {can(user, "workflow:view") && ( | ||||||
|           </main> |               <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/workflow">Workflow (n8n)</Link> | ||||||
|  |             )} | ||||||
|  |             {can(user, "health:view") && ( | ||||||
|  |               <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/health">Health</Link> | ||||||
|  |             )} | ||||||
|  |             {can(user, "users:manage") && ( | ||||||
|  |               <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/users">ผู้ใช้/บทบาท</Link> | ||||||
|  |             )} | ||||||
|  |           </nav> | ||||||
|         </div> |         </div> | ||||||
|       </body> |       </aside> | ||||||
|     </html> |  | ||||||
|  |       <main className="col-span-12 space-y-6 lg:col-span-9 xl:col-span-9"> | ||||||
|  |         {/* System / Quick Actions */} | ||||||
|  |         <div className="flex items-center gap-2"> | ||||||
|  |           <div className="flex-1 text-lg font-semibold">Document Management System — LCBP3 Phase 3</div> | ||||||
|  |  | ||||||
|  |           {can(user, "admin:view") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/admin">Admin</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "users:manage") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/users">ผู้ใช้/บทบาท</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "health:view") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/health">Health</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "workflow:view") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/workflow">Workflow</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "rfa:create") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/rfas/new">+ RFA</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "drawing:upload") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/drawings/upload">+ Upload Drawing</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "transmittal:create") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/transmittals/new">+ Transmittal</a> | ||||||
|  |           )} | ||||||
|  |           {can(user, "correspondence:create") && ( | ||||||
|  |             <a className="px-3 py-2 text-white rounded-xl" style={{ background: "#0D5C75" }} href="/correspondences/new">+ หนังสือสื่อสาร</a> | ||||||
|  |           )} | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {children} | ||||||
|  |       </main> | ||||||
|  |     </section> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user