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>
This commit is contained in:
parent
460cd9c600
commit
65b72cf817
16 changed files with 414 additions and 131 deletions
132
components/LoginForm.tsx
Normal file
132
components/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"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'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>
|
||||
);
|
||||
}
|
||||
29
components/LogoutButton.tsx
Normal file
29
components/LogoutButton.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง
|
||||
import { useTransition } from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
import { logout } from "@/app/actions/auth";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const onLogout = () => {
|
||||
startTransition(async () => {
|
||||
await logout();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={pending}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden />
|
||||
{pending ? "กำลังออก…" : "ออกจากระบบ"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
15
components/SessionBar.tsx
Normal file
15
components/SessionBar.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component
|
||||
// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้)
|
||||
import LogoutButton from "./LogoutButton";
|
||||
|
||||
export default function SessionBar({ userName }: { userName: string }) {
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<p className="min-w-0 text-sm text-slate-600">
|
||||
เข้าสู่ระบบเป็น{" "}
|
||||
<span className="font-semibold text-slate-900">{userName}</span>
|
||||
</p>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
"use client";
|
||||
|
||||
// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม
|
||||
// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร)
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Select from "./Select";
|
||||
import {
|
||||
MOCK_USERS,
|
||||
getStoredLocalUser,
|
||||
setLocalUser,
|
||||
type LocalUser,
|
||||
} from "@/utils/user";
|
||||
|
||||
export default function UserPicker() {
|
||||
const [current, setCurrent] = useState<LocalUser | null>(null);
|
||||
|
||||
// ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน
|
||||
useEffect(() => {
|
||||
const stored = getStoredLocalUser();
|
||||
if (!stored) {
|
||||
// ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก
|
||||
setLocalUser(MOCK_USERS[0]);
|
||||
setCurrent(MOCK_USERS[0]);
|
||||
return;
|
||||
}
|
||||
setCurrent(stored);
|
||||
}, []);
|
||||
|
||||
// identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร
|
||||
const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId);
|
||||
|
||||
const select = (userId: string) => {
|
||||
const user = MOCK_USERS.find((u) => u.userId === userId);
|
||||
if (!user) return;
|
||||
setLocalUser(user);
|
||||
setCurrent(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-6 rounded-lg border bg-white p-4 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-primary">เลือกผู้ใช้ (mock)</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก
|
||||
</p>
|
||||
<Select
|
||||
value={current ? (isMock ? current.userId : "") : MOCK_USERS[0].userId}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) select(e.target.value);
|
||||
}}
|
||||
className="mt-3 block w-full rounded-md border bg-white py-2 pl-3 text-sm"
|
||||
>
|
||||
{current && !isMock && (
|
||||
<option value="" disabled>
|
||||
{current.userName} (สุ่มอัตโนมัติ)
|
||||
</option>
|
||||
)}
|
||||
{MOCK_USERS.map((u) => (
|
||||
<option key={u.userId} value={u.userId}>
|
||||
{u.userName}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ import {
|
|||
type Rect,
|
||||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import { getLocalUser, type LocalUser } from "@/utils/user";
|
||||
import type { OacUser } from "@/utils/user";
|
||||
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { decodeTiffPages } from "@/utils/tiff-client";
|
||||
import {
|
||||
|
|
@ -1138,11 +1138,14 @@ export default function PDFViewer({
|
|||
initialAnnotations,
|
||||
initialBookmarks,
|
||||
initialReplies,
|
||||
user,
|
||||
}: {
|
||||
file: { id: string; title: string; pdfUrl: string };
|
||||
initialAnnotations: AnnotationDTO[];
|
||||
initialBookmarks: BookmarkDTO[];
|
||||
initialReplies: ReplyDTO[];
|
||||
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */
|
||||
user: OacUser;
|
||||
}) {
|
||||
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
|
||||
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
|
||||
|
|
@ -1194,7 +1197,6 @@ export default function PDFViewer({
|
|||
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [user, setUser] = useState<LocalUser | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
// tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ
|
||||
const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null);
|
||||
|
|
@ -1323,11 +1325,10 @@ export default function PDFViewer({
|
|||
[replies, visibleAnnotations],
|
||||
);
|
||||
|
||||
// anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage)
|
||||
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
|
||||
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
|
||||
// (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect)
|
||||
useEffect(() => {
|
||||
setUser(getLocalUser());
|
||||
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
|
||||
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue