Compare commits
	
		
			2 Commits
		
	
	
		
			bf3d9fc1d0
			...
			a3d2e24861
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a3d2e24861 | ||
|   | 2215633fb9 | 
| @@ -81,7 +81,7 @@ services: | ||||
|       DB_USER: "center" | ||||
|       DB_PASSWORD: "Center#2025" | ||||
|       DB_NAME: "dms" | ||||
|       JWT_SECRET: "8b0df02e4aee9f9f79a4f2d8ba77b0b82c1ee3446b68cb0bae94ab54d60f8d9e" | ||||
|       JWT_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||
|       JWT_EXPIRES_IN: "12h" | ||||
|       PASSWORD_SALT_ROUNDS: "10" | ||||
|       FRONTEND_ORIGIN: "https://lcbp3.np-dms.work" | ||||
| @@ -130,10 +130,11 @@ services: | ||||
|       CHOKIDAR_USEPOLLING: "1" | ||||
|       WATCHPACK_POLLING: "true" | ||||
|       NEXT_PUBLIC_API_BASE: "https://lcbp3.np-dms.work" | ||||
|       NEXT_PUBLIC_AUTH_MODE: "cookie" | ||||
|       NEXT_PUBLIC_DEBUG_AUTH: "1" | ||||
|       NEXT_TELEMETRY_DISABLED: "1" | ||||
|       JWT_ACCESS_SECRET: "change-this-access-secret" | ||||
|       JWT_REFRESH_SECRET: "change-this-refresh-secret" | ||||
|       JWT_ACCESS_SECRET: "9a6d8705a6695ab9bae4ca1cd46c72a6379aa72404b96e2c5b59af881bb55c639dd583afdce5a885c68e188da55ce6dbc1fb4aa9cd4055ceb51507e56204e4ca" | ||||
|       JWT_REFRESH_SECRET: "743e798bb10d6aba168bf68fc3cf8eff103c18bd34f1957a3906dc87987c0df139ab72498f2fe20d6c4c580f044ccba7d7bfa4393ee6035b73ba038f28d7480c" | ||||
|     expose: | ||||
|       - "3000" | ||||
|     networks: [dmsnet] | ||||
|   | ||||
| @@ -1,54 +1,34 @@ | ||||
| // File: frontend/app/(auth)/login/page.jsx | ||||
|  | ||||
| "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 { useSearchParams, useRouter } from "next/navigation"; | ||||
| import { | ||||
|   Card, | ||||
|   CardHeader, | ||||
|   CardTitle, | ||||
|   CardDescription, | ||||
|   CardContent, | ||||
|   CardFooter, | ||||
|   Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, | ||||
| } from "@/components/ui/card"; | ||||
| import { Label } from "@/components/ui/label"; | ||||
| import { Input } from "@/components/ui/input"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| 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 = | ||||
|   String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" && | ||||
|   process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" && | ||||
|   process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false"; | ||||
|  | ||||
| function dlog(...args) { | ||||
|   if (DEBUG && typeof window !== "undefined") { | ||||
|     console.debug("[login]", ...args); | ||||
|   } | ||||
|   if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args); | ||||
| } | ||||
|  | ||||
| function LoginForm() { | ||||
|   const router = useRouter(); | ||||
|   const searchParams = useSearchParams(); | ||||
|   const nextPath = useMemo( | ||||
|     () => searchParams.get("next") || "/dashboard", | ||||
|     [searchParams] | ||||
|   ); | ||||
|   const nextPath = useMemo(() => searchParams.get("next") || "/dashboard", [searchParams]); | ||||
|  | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [showPw, setShowPw] = useState(false); | ||||
|   const [remember, setRemember] = useState(false); | ||||
|   const [submitting, setSubmitting] = useState(false); | ||||
|   const [err, setErr] = useState(""); | ||||
|  | ||||
| @@ -63,69 +43,37 @@ function LoginForm() { | ||||
|  | ||||
|     try { | ||||
|       setSubmitting(true); | ||||
|  | ||||
|       // ── DEBUG: ค่าเบื้องต้น | ||||
|       dlog("API_BASE =", API_BASE || "(empty → จะเรียก path relative)"); | ||||
|       dlog("nextPath =", nextPath); | ||||
|       dlog("remember =", remember); | ||||
|       dlog("payload =", { username: "[hidden]", password: "[hidden]" }); | ||||
|       dlog("API_BASE =", API_BASE || "(empty → relative path)"); dlog("nextPath =", nextPath); | ||||
|  | ||||
|       const res = await fetch(`${API_BASE}/api/auth/login`, { | ||||
|         method: "POST", | ||||
|         headers: { "Content-Type": "application/json" }, | ||||
|         body: JSON.stringify({ username, password }), | ||||
|         credentials: "include",         // << ใช้คุกกี้ | ||||
|         cache: "no-store", | ||||
|         body: JSON.stringify({ username, password }), | ||||
|       }); | ||||
|  | ||||
|       dlog("response.status =", res.status); | ||||
|       dlog("response.headers.content-type =", res.headers.get("content-type")); | ||||
|  | ||||
|       dlog("status =", res.status, "ctype =", res.headers.get("content-type")); | ||||
|       let data = {}; | ||||
|       try { | ||||
|         data = await res.json(); | ||||
|       } catch (e) { | ||||
|         dlog("response.json() error =", e); | ||||
|       } | ||||
|       dlog("response.body =", data); | ||||
|       try { data = await res.json(); } catch {} | ||||
|  | ||||
|       if (!res.ok) { | ||||
|         const msg = | ||||
|           data?.error === "INVALID_CREDENTIALS" | ||||
|             ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง" | ||||
|             : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`; | ||||
|         dlog("login FAILED →", msg); | ||||
|         setErr(msg); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (!data?.token) { | ||||
|         dlog("login FAILED → data.token not found"); | ||||
|         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); | ||||
|       // คุกกี้ (HttpOnly) ถูกตั้งด้วย Set-Cookie จาก backend แล้ว | ||||
|       dlog("login ok → redirect", nextPath); | ||||
|       router.replace(nextPath); | ||||
|     } catch (e) { | ||||
|       dlog("exception =", e); | ||||
|       setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่"); | ||||
|     } finally { | ||||
|       setSubmitting(false); | ||||
|       dlog("done"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -133,32 +81,22 @@ function LoginForm() { | ||||
|     <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"> | ||||
|         <CardHeader className="space-y-1"> | ||||
|           <CardTitle className="text-2xl font-bold text-sky-800"> | ||||
|             เข้าสู่ระบบ | ||||
|           </CardTitle> | ||||
|           <CardDescription className="text-sky-700"> | ||||
|             Document Management System • LCBP3 | ||||
|           </CardDescription> | ||||
|           <CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle> | ||||
|           <CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription> | ||||
|         </CardHeader> | ||||
|  | ||||
|         <CardContent> | ||||
|           {err ? ( | ||||
|             <Alert className="mb-4"> | ||||
|               <AlertDescription>{err}</AlertDescription> | ||||
|             </Alert> | ||||
|             <Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert> | ||||
|           ) : null} | ||||
|  | ||||
|           <form onSubmit={onSubmit} className="grid gap-4"> | ||||
|             <div className="grid gap-2"> | ||||
|               <Label htmlFor="username">ชื่อผู้ใช้</Label> | ||||
|               <Input | ||||
|                 id="username" | ||||
|                 autoFocus | ||||
|                 autoComplete="username" | ||||
|                 value={username} | ||||
|                 onChange={(e) => setUsername(e.target.value)} | ||||
|                 placeholder="เช่น superadmin" | ||||
|                 disabled={submitting} | ||||
|                 id="username" autoFocus autoComplete="username" | ||||
|                 value={username} onChange={(e) => setUsername(e.target.value)} | ||||
|                 placeholder="เช่น superadmin" disabled={submitting} | ||||
|               /> | ||||
|             </div> | ||||
|  | ||||
| @@ -166,59 +104,22 @@ function LoginForm() { | ||||
|               <Label htmlFor="password">รหัสผ่าน</Label> | ||||
|               <div className="relative"> | ||||
|                 <Input | ||||
|                   id="password" | ||||
|                   type={showPw ? "text" : "password"} | ||||
|                   autoComplete="current-password" | ||||
|                   value={password} | ||||
|                   onChange={(e) => setPassword(e.target.value)} | ||||
|                   placeholder="••••••••" | ||||
|                   disabled={submitting} | ||||
|                   className="pr-10" | ||||
|                   id="password" type={showPw ? "text" : "password"} autoComplete="current-password" | ||||
|                   value={password} onChange={(e) => setPassword(e.target.value)} | ||||
|                   placeholder="••••••••" disabled={submitting} className="pr-10" | ||||
|                 /> | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   onClick={() => setShowPw((v) => !v)} | ||||
|                   type="button" 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" | ||||
|                   aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} | ||||
|                   disabled={submitting} | ||||
|                   aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting} | ||||
|                 > | ||||
|                   {showPw ? "Hide" : "Show"} | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div className="flex items-center justify-between pt-1"> | ||||
|               <label className="inline-flex items-center gap-2 text-sm text-slate-600"> | ||||
|                 <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 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> | ||||
|  | ||||
|             {DEBUG ? ( | ||||
| @@ -245,18 +146,13 @@ export default function LoginPage() { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** Loading skeleton */ | ||||
| function LoginPageSkeleton() { | ||||
|   return ( | ||||
|     <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"> | ||||
|         <CardHeader className="space-y-1"> | ||||
|           <CardTitle className="text-2xl font-bold text-sky-800"> | ||||
|             เข้าสู่ระบบ | ||||
|           </CardTitle> | ||||
|           <CardDescription className="text-sky-700"> | ||||
|             Document Management System • LCBP3 | ||||
|           </CardDescription> | ||||
|           <CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle> | ||||
|           <CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription> | ||||
|         </CardHeader> | ||||
|         <CardContent> | ||||
|           <div className="grid gap-4 animate-pulse"> | ||||
| @@ -270,28 +166,11 @@ function LoginPageSkeleton() { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** Spinner แบบไม่พึ่งไลบรารีเสริม */ | ||||
| function Spinner() { | ||||
|   return ( | ||||
|     <svg | ||||
|       className="animate-spin size-4" | ||||
|       viewBox="0 0 24 24" | ||||
|       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 className="animate-spin size-4" viewBox="0 0 24 24" 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> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										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 | ||||
| import "./globals.css"; | ||||
| import { Inter } from "next/font/google"; | ||||
| // File: frontend/app/(protected)/layout.jsx | ||||
| import Link from "next/link"; | ||||
| 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 = { | ||||
|   title: "DMS", | ||||
|   description: "Document Management System", | ||||
| }; | ||||
| const API_BASE = (process.env.NEXT_PUBLIC_API_BASE || "").replace(/\/$/, ""); | ||||
|  | ||||
| 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 ( | ||||
|     <html lang="en" suppressHydrationWarning> | ||||
|       <body | ||||
|         className={`${inter.className} min-h-screen bg-background text-foreground`} | ||||
|       > | ||||
|         <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> | ||||
|     <section className="grid grid-cols-12 gap-6 p-4 mx-auto max-w-7xl"> | ||||
|       <aside className="col-span-12 lg:col-span-3 xl:col-span-3"> | ||||
|         <div className="p-4 border rounded-3xl bg-white/70"> | ||||
|           <div className="mb-3 text-sm">RBAC: <b>{user.role}</b></div> | ||||
|  | ||||
|           <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> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/dashboard">แดชบอร์ด</Link> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/drawings">Drawings</Link> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/rfas">RFAs</Link> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/transmittals">Transmittals</Link> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/correspondences">Correspondences</Link> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/contracts-volumes">Contracts & Volumes</Link> | ||||
|             <Link className="block px-4 py-2 rounded-xl bg-white/60 hover:bg-white" href="/reports">Reports</Link> | ||||
|  | ||||
|             {can(user, "workflow:view") && ( | ||||
|               <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> | ||||
|       </aside> | ||||
|  | ||||
|           {/* Main content */} | ||||
|           <main className="flex-1 flex flex-col"> | ||||
|             {/* Top Navbar */} | ||||
|             <header className="h-14 bg-secondary text-secondary-foreground flex items-center justify-between px-6 shadow-sm"> | ||||
|               <span className="font-medium">Laem Chabang Port Phase 3</span> | ||||
|               <div className="flex items-center space-x-4"> | ||||
|                 <button className="px-3 py-1 rounded bg-accent text-accent-foreground hover:opacity-90"> | ||||
|                   Quick Action | ||||
|                 </button> | ||||
|                 <span className="text-sm">superadmin</span> | ||||
|               </div> | ||||
|             </header> | ||||
|       <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> | ||||
|  | ||||
|             <section className="p-6 flex-1">{children}</section> | ||||
|           </main> | ||||
|           {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> | ||||
|       </body> | ||||
|     </html> | ||||
|  | ||||
|         {children} | ||||
|       </main> | ||||
|     </section> | ||||
|   ); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user