177 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			177 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
| // File: frontend/app/(auth)/login/page.jsx
 | |
| "use client";
 | |
| 
 | |
| import { useState, useMemo, Suspense } from "react";
 | |
| import { useSearchParams, useRouter } from "next/navigation";
 | |
| import {
 | |
|   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 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);
 | |
| }
 | |
| 
 | |
| function LoginForm() {
 | |
|   const router = useRouter();
 | |
|   const searchParams = useSearchParams();
 | |
|   const nextPath = useMemo(() => searchParams.get("next") || "/dashboard", [searchParams]);
 | |
| 
 | |
|   const [username, setUsername] = useState("");
 | |
|   const [password, setPassword] = useState("");
 | |
|   const [showPw, setShowPw] = useState(false);
 | |
|   const [submitting, setSubmitting] = useState(false);
 | |
|   const [err, setErr] = useState("");
 | |
| 
 | |
|   async function onSubmit(e) {
 | |
|     e.preventDefault();
 | |
|     setErr("");
 | |
| 
 | |
|     if (!username.trim() || !password) {
 | |
|       setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|       setSubmitting(true);
 | |
|       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" },
 | |
|         credentials: "include",         // << ใช้คุกกี้
 | |
|         cache: "no-store",
 | |
|         body: JSON.stringify({ username, password }),
 | |
|       });
 | |
| 
 | |
|       dlog("status =", res.status, "ctype =", res.headers.get("content-type"));
 | |
|       let data = {};
 | |
|       try { data = await res.json(); } catch {}
 | |
| 
 | |
|       if (!res.ok) {
 | |
|         const msg =
 | |
|           data?.error === "INVALID_CREDENTIALS"
 | |
|             ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
 | |
|             : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
 | |
|         setErr(msg);
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // คุกกี้ (HttpOnly) ถูกตั้งด้วย Set-Cookie จาก backend แล้ว
 | |
|       dlog("login ok → redirect", nextPath);
 | |
|       router.replace(nextPath);
 | |
|     } catch (e) {
 | |
|       dlog("exception =", e);
 | |
|       setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
 | |
|     } finally {
 | |
|       setSubmitting(false);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   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>
 | |
|         </CardHeader>
 | |
| 
 | |
|         <CardContent>
 | |
|           {err ? (
 | |
|             <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}
 | |
|               />
 | |
|             </div>
 | |
| 
 | |
|             <div className="grid gap-2">
 | |
|               <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"
 | |
|                 />
 | |
|                 <button
 | |
|                   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}
 | |
|                 >
 | |
|                   {showPw ? "Hide" : "Show"}
 | |
|                 </button>
 | |
|               </div>
 | |
|             </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>
 | |
| 
 | |
|             {DEBUG ? (
 | |
|               <p className="mt-2 text-xs text-slate-500">
 | |
|                 DEBUG: NEXT_PUBLIC_API_BASE = <code>{API_BASE || "(empty)"}</code>
 | |
|               </p>
 | |
|             ) : null}
 | |
|           </form>
 | |
|         </CardContent>
 | |
| 
 | |
|         <CardFooter className="text-xs text-center text-slate-500">
 | |
|           © {new Date().getFullYear()} np-dms.work
 | |
|         </CardFooter>
 | |
|       </Card>
 | |
|     </div>
 | |
|   );
 | |
| }
 | |
| 
 | |
| export default function LoginPage() {
 | |
|   return (
 | |
|     <Suspense fallback={<LoginPageSkeleton />}>
 | |
|       <LoginForm />
 | |
|     </Suspense>
 | |
|   );
 | |
| }
 | |
| 
 | |
| 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>
 | |
|         </CardHeader>
 | |
|         <CardContent>
 | |
|           <div className="grid gap-4 animate-pulse">
 | |
|             <div className="h-10 rounded bg-slate-200"></div>
 | |
|             <div className="h-10 rounded bg-slate-200"></div>
 | |
|             <div className="h-10 rounded bg-slate-200"></div>
 | |
|           </div>
 | |
|         </CardContent>
 | |
|       </Card>
 | |
|     </div>
 | |
|   );
 | |
| }
 | |
| 
 | |
| 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>
 | |
|   );
 | |
| }
 | 
