eCourt-note/components/LoginForm.tsx

133 lines
6 KiB
TypeScript
Raw Normal View History

mock login e-Court หน้า /login + identity จาก cookie oacCase แทน localStorage — dropdown ผู้ใช้ MOCK_USERS (user1–user5, utils/user.ts pure module ให้ client+server ใช้ร่วม) รหัสผ่านกลาง Demo1234 ผ่าน server action login (เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + delay 500ms ให้เห็น "กำลังตรวจสอบ…") ตั้ง cookie oacCase ตาม format จริงของระบบศาล 16 field — u_userid/u_userlogin ต่างกันต่อคน (= ชื่อผู้ใช้) เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย userId) แยกกันจริงทั้ง 5 คน httpOnly/sameSite lax/8 ชม. + middleware.ts บังคับ login ทุกหน้า (parse จริงด้วย parseOacCase ไม่ใช่แค่มี cookie — ขยะ/ว่าง/ขาด field = redirect /login; matcher ยกเว้น /login, /api/*, /uploads/*, _next และ static ใน public/ — SSE/export จึงไม่โดน 302) + lib/session.ts getSessionUser อ่าน identity ฝั่ง server ใช้ในทุกหน้า (defense ซ้อน middleware) + PDFViewer รับ user เป็น prop จาก annotate page ตั้งแต่ first paint (หมดช่วง null user ที่เคยสร้าง annotation ด้วย userId="" ได้ + ไม่มี hydration mismatch; Server Actions ไม่ต้องแก้ — vString รับ userId ใหม่อยู่แล้ว) + SessionBar แสดง "เข้าสู่ระบบเป็น <user>" + ปุ่มออกจากระบบ (ลบ cookie ด้วย path "/" ตรงตอน set + redirect ใน action เอง — client router.replace เองชนกับ re-render ของหน้าที่เพิ่งเสีย session จน navigation ไม่เกิด) แทน UserPicker mock 10 คน (ลบไฟล์พร้อม MOCK_USERS/localStorage/getLocalUser ทั้งชุด) + LoginForm การ์ด max-w-sm กลางจอ reuse brand lockup และปุ่ม primary เดิม, input text-base 16px กัน iOS zoom บน iPad, dropdown placeholder "เลือกผู้ใช้" บังคับเลือก (ไม่ preselect กัน login ผิดคนโดยไม่ตั้งใจ), error แถบเดียว role=alert "รหัสผ่านไม่ถูกต้อง" (dropdown เลือกผิดไม่ได้แล้ว), ตอนผิดล้าง password คงผู้ใช้ที่เลือกไว้ + focus+select + tests/utils/user.test.ts ล็อก format จริงของ e-Court (parse ตัวอย่าง cookie จริง 235821/tu_2, garbage/empty/ขาด field → null, round-trip, buildOacCaseCookie ครบ 16 field) + sync CLAUDE.md/PLAN.md §0 A4/README — annotation สมัย mock-user-01/UUID ไม่ใช่ของตัวเองอีก (auth จริงเทียบระบบศาล + roles ยัง out of scope; /api/* และ /uploads/* ยังไม่ต้อง login ตามขอบเขต mock) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:41:02 +07:00
"use client";
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/")
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/app/actions/auth";
import Select from "@/components/Select";
import { MOCK_USERS } from "@/utils/user";
// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad
const INPUT_CLASS =
"mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30";
// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน)
const SELECT_CLASS =
"block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50";
export default function LoginForm() {
const router = useRouter();
const passwordRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setError(null);
const usernameInput = form.elements.namedItem("username") as HTMLSelectElement;
const passwordInput = form.elements.namedItem("password") as HTMLInputElement;
startTransition(async () => {
try {
const result = await login(usernameInput.value, passwordInput.value);
if (result.ok) {
router.replace("/");
} else {
setError(result.error);
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
passwordInput.value = "";
passwordRef.current?.focus();
passwordRef.current?.select();
}
} catch {
setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง");
}
});
};
return (
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
{/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
JN
</div>
<div>
<p className="text-lg font-bold leading-tight text-primary">Judge&apos;s Note</p>
<p className="text-xs text-slate-500">ระบบ PDF Annotation สำหรับ e-Court</p>
</div>
</div>
<h1 className="mt-6 text-lg font-semibold text-slate-900">เข้าสู่ระบบ</h1>
<form onSubmit={onSubmit} className="mt-4">
<div>
<label htmlFor="username" className="text-sm font-semibold text-slate-700">
ชื่อผู้ใช้
</label>
<div className="mt-1">
<Select
id="username"
name="username"
autoComplete="username"
required
autoFocus
disabled={pending}
defaultValue=""
className={SELECT_CLASS}
>
{/* placeholder บังคับเลือก — ไม่ preselect คนแรก กันผู้ทดสอบ login ผิดคนโดยไม่ตั้งใจ */}
<option value="" disabled>
เลือกผู้ใช้
</option>
{MOCK_USERS.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</Select>
</div>
</div>
<div className="mt-4">
<label htmlFor="password" className="text-sm font-semibold text-slate-700">
รหัสผ่าน
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={pending}
aria-invalid={!!error || undefined}
ref={passwordRef}
className={INPUT_CLASS}
/>
</div>
<button
type="submit"
disabled={pending}
className="mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
{pending ? "กำลังตรวจสอบ…" : "เข้าสู่ระบบ"}
</button>
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */}
<p className="mt-5 rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
รหัสผ่านทดสอบ:{" "}
<span className="font-semibold text-slate-700">Demo1234</span>
</p>
{error && (
<p role="alert" className="mt-3 text-xs text-red-600">
{error}
</p>
)}
</form>
</div>
);
}