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
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -13,7 +13,7 @@
|
|||
- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules)
|
||||
- Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`)
|
||||
- Tailwind CSS
|
||||
- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`)
|
||||
- mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope)
|
||||
|
||||
## Invariants ที่ต้องรักษา (ห้ามละเมิด)
|
||||
|
||||
|
|
@ -34,26 +34,30 @@
|
|||
```
|
||||
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
||||
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/)
|
||||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้)
|
||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||
utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
|
||||
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
|
||||
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route)
|
||||
utils/user.ts ← anonymous identity (localStorage)
|
||||
utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้)
|
||||
utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed)
|
||||
utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
|
||||
utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route)
|
||||
hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id
|
||||
hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2)
|
||||
app/actions/annotations.ts ← Server Actions CRUD + validation + publish event
|
||||
app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie)
|
||||
app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4)
|
||||
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
|
||||
app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
|
||||
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
|
||||
app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ)
|
||||
app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17)
|
||||
app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||
app/page.tsx ← รายการไฟล์ (server component)
|
||||
app/login/page.tsx ← หน้า login (มี session แล้ว redirect /)
|
||||
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
||||
app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15)
|
||||
components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)
|
||||
|
|
@ -62,13 +66,15 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18)
|
|||
components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ
|
||||
components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1)
|
||||
components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4)
|
||||
components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/"))
|
||||
components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม)
|
||||
```
|
||||
|
||||
## ขอบเขต — ห้ามทำเกิน (Out of Scope)
|
||||
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md`)
|
||||
> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม)
|
||||
|
||||
- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
|
||||
- ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions`
|
||||
- Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6)
|
||||
- ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น
|
||||
- หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
- **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม
|
||||
- **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด)
|
||||
- **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น
|
||||
- ไม่มีระบบ login — anonymous identity เก็บใน `localStorage` (ปุ่มลบแสดงเฉพาะรายการของตัวเอง)
|
||||
- หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง)
|
||||
|
||||
## สิ่งที่ต้องมี (Prerequisites)
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ npm run dev
|
|||
| bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน |
|
||||
| zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) |
|
||||
|
||||
Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (anonymous identity จาก `localStorage`)
|
||||
Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login)
|
||||
|
||||
ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ db/schema.ts ตาราง + JSONB strict union + DTO + toDTO
|
|||
db/index.ts postgres.js + drizzle (prepare: false)
|
||||
lib/annotate-events.ts pub/sub in-process (actions → SSE route)
|
||||
utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
|
||||
utils/user.ts anonymous identity (localStorage)
|
||||
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
||||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
|
|
|
|||
41
app/actions/auth.ts
Normal file
41
app/actions/auth.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use server";
|
||||
|
||||
// mock login e-Court: รับเฉพาะผู้ใช้ใน MOCK_USERS (user1–user5) + Demo1234
|
||||
// → ตั้ง cookie oacCase ตาม format จริง (identity ของแอปอ่านผ่าน lib/session.ts
|
||||
// — บังคับด้วย middleware.ts)
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { buildOacCaseCookie, MOCK_USERS, OAC_CASE_COOKIE } from "@/utils/user";
|
||||
|
||||
const MOCK_PASSWORD = "Demo1234";
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// mock ตอบเร็วกว่าตา — หน่วงเล็กน้อยให้เห็นสถานะ "กำลังตรวจสอบ…"
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const name = username.trim();
|
||||
// username มาจาก dropdown เลือกผิดไม่ได้ — เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft
|
||||
if (!MOCK_USERS.includes(name) || password !== MOCK_PASSWORD) {
|
||||
return { ok: false, error: "รหัสผ่านไม่ถูกต้อง" };
|
||||
}
|
||||
// u_userid ต่างกันต่อคน (= u_userlogin) — สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย
|
||||
// userId) ต้องแยกกันจริงทั้ง 5 คน ห้ามแชร์ค่าเดียว
|
||||
cookies().set(OAC_CASE_COOKIE, buildOacCaseCookie(name, name), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 8 * 60 * 60,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
|
||||
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
|
||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||
redirect("/login");
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "@/db/schema";
|
||||
import PDFViewer from "@/components/pdf/PDFViewer";
|
||||
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
|
@ -27,6 +28,10 @@ export default async function AnnotatePage({
|
|||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) notFound();
|
||||
|
||||
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
|
|
@ -62,6 +67,7 @@ export default async function AnnotatePage({
|
|||
initialAnnotations={annotationRows.map(toAnnotationDTO)}
|
||||
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
|
||||
initialReplies={replyRows.map(toReplyDTO)}
|
||||
user={user}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
21
app/login/page.tsx
Normal file
21
app/login/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import LoginForm from "@/components/LoginForm";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" };
|
||||
|
||||
export default function LoginPage() {
|
||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||
const user = getSessionUser();
|
||||
if (user) redirect("/");
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<LoginForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
10
app/page.tsx
10
app/page.tsx
|
|
@ -1,12 +1,14 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileImage, FileText } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import UserPicker from "@/components/UserPicker";
|
||||
import SessionBar from "@/components/SessionBar";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -25,6 +27,10 @@ function kindBadge(pdfUrl: string): string {
|
|||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
return (
|
||||
|
|
@ -43,7 +49,7 @@ export default async function HomePage() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<UserPicker />
|
||||
<SessionBar userName={user.userName} />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>
|
||||
|
|
|
|||
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)
|
||||
useEffect(() => {
|
||||
setUser(getLocalUser());
|
||||
// UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount
|
||||
// ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม)
|
||||
// (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect)
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
| A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว |
|
||||
| A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict |
|
||||
| A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 |
|
||||
| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง |
|
||||
| A4 | **Identity (ปรับปรุง 2026-09-17 — mock login)** | หน้า `/login` mock e-Court (dropdown ผู้ใช้ `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` เป็น query string ตาม format จริงของระบบศาล → identity อ่านจาก cookie: `userId` = `u_userid`, `userName` = `u_userlogin` (**ต่างกันต่อคน** = ชื่อผู้ใช้ เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" แยกกันจริง) ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope; เดิมเป็น anonymous user ใน `localStorage` — annotation ที่สร้างสมัย `mock-user-01`/UUID จึงไม่ใช่ของตัวเองอีก) |
|
||||
| A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/<uuid>.<ext>` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) |
|
||||
| A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ |
|
||||
| A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime |
|
||||
|
|
@ -102,7 +102,7 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div
|
|||
| id | uuid PK | |
|
||||
| file_id | uuid FK → files ON DELETE CASCADE | |
|
||||
| page_index | integer NOT NULL | 0-based |
|
||||
| user_id | text NOT NULL | anonymous id จาก localStorage |
|
||||
| user_id | text NOT NULL | จาก cookie oacCase (`u_userid`) |
|
||||
| user_name | text NOT NULL | |
|
||||
| type | annotation_type NOT NULL | |
|
||||
| data | jsonb NOT NULL `$type<AnnotationData>()` | **strict typed union** (ด้านล่าง) |
|
||||
|
|
@ -171,7 +171,7 @@ judes-note/
|
|||
│ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe)
|
||||
├── utils/
|
||||
│ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก)
|
||||
│ └── user.ts ← anonymous user identity (localStorage)
|
||||
│ └── user.ts ← parse/build ค่า cookie oacCase → identity (mock login e-Court)
|
||||
├── hooks/
|
||||
│ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters
|
||||
├── app/
|
||||
|
|
@ -248,7 +248,7 @@ judes-note/
|
|||
|
||||
## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope)
|
||||
|
||||
- ระบบ login/roles (ใช้ anonymous identity)
|
||||
- auth จริงเทียบระบบ e-Court + roles/สิทธิ์ — login ปัจจุบันเป็น mock หน้า `/login` (ตั้ง cookie `oacCase` ตาม format จริง แต่ไม่ได้ตรวจกับระบบศาลจริง)
|
||||
- แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation`
|
||||
- Undo/redo, export PDF ที่ฝัง annotation
|
||||
- แสดงรายชื่อ "ใครออนไลน์อยู่" (presence)
|
||||
|
|
|
|||
8
lib/session.ts
Normal file
8
lib/session.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user";
|
||||
|
||||
export function getSessionUser(): OacUser | null {
|
||||
return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value);
|
||||
}
|
||||
24
middleware.ts
Normal file
24
middleware.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
import { OAC_CASE_COOKIE, parseOacCase } from "@/utils/user";
|
||||
|
||||
// บังคับ login (mock e-Court) ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login
|
||||
// (parse จริง ไม่ใช่แค่มี cookie — cookie ขยะ/ครึ่ง ๆ ถือว่ายังไม่เข้าสู่ระบบ)
|
||||
export function middleware(req: NextRequest) {
|
||||
const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value);
|
||||
if (!user) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.search = "";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/ (pdf.worker, samples, fonts, favicon)
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)",
|
||||
],
|
||||
};
|
||||
58
tests/utils/user.test.ts
Normal file
58
tests/utils/user.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOacCaseCookie,
|
||||
OAC_CASE_COOKIE,
|
||||
parseOacCase,
|
||||
} from "@/utils/user";
|
||||
|
||||
/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */
|
||||
const REAL_OAC_CASE =
|
||||
"u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName=";
|
||||
|
||||
describe("parseOacCase", () => {
|
||||
it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => {
|
||||
expect(parseOacCase(REAL_OAC_CASE)).toEqual({
|
||||
userId: "235821",
|
||||
userName: "tu_2",
|
||||
});
|
||||
});
|
||||
|
||||
it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => {
|
||||
expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({
|
||||
userId: "user1",
|
||||
userName: "user1",
|
||||
});
|
||||
});
|
||||
|
||||
it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => {
|
||||
expect(parseOacCase(null)).toBeNull();
|
||||
expect(parseOacCase(undefined)).toBeNull();
|
||||
expect(parseOacCase("")).toBeNull();
|
||||
expect(parseOacCase("garbage")).toBeNull();
|
||||
expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid
|
||||
expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง
|
||||
});
|
||||
|
||||
it("ค่ารอบด้วย space ถูก trim", () => {
|
||||
expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({
|
||||
userId: "123",
|
||||
userName: "tu_2",
|
||||
});
|
||||
});
|
||||
|
||||
it("ชื่อ cookie ตรงกับระบบ e-Court", () => {
|
||||
expect(OAC_CASE_COOKIE).toBe("oacCase");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOacCaseCookie", () => {
|
||||
it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => {
|
||||
const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x")));
|
||||
expect(fields["u_userid"]).toBe("xxxxxx");
|
||||
expect(fields["u_userlogin"]).toBe("user_x");
|
||||
expect(fields["u_courtid"]).toBe("4");
|
||||
expect(fields["u_perCardNo"]).toBe("5000000000006");
|
||||
expect(fields["u_AuditID"]).toBe("1995227");
|
||||
expect(Object.keys(fields)).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,51 +1,53 @@
|
|||
// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4)
|
||||
// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth
|
||||
// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase"
|
||||
// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม
|
||||
// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers
|
||||
|
||||
export type LocalUser = { userId: string; userName: string };
|
||||
export type OacUser = { userId: string; userName: string };
|
||||
|
||||
const STORAGE_KEY = "judes-note:user";
|
||||
export const OAC_CASE_COOKIE = "oacCase";
|
||||
|
||||
// mock users สำหรับเลือกที่หน้าแรก (demo ไม่มีระบบ auth) — userId คงที่
|
||||
// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh
|
||||
export const MOCK_USERS: LocalUser[] = [
|
||||
{ userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" },
|
||||
{ userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" },
|
||||
{ userId: "mock-user-03", userName: "อัยการ วิชัย" },
|
||||
{ userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" },
|
||||
{ userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" },
|
||||
{ userId: "mock-user-06", userName: "ทนายจำเลย อรุณี" },
|
||||
{ userId: "mock-user-07", userName: "ผู้ช่วยผู้พิพากษา กันต์" },
|
||||
{ userId: "mock-user-08", userName: "พนักงานสอบสวน เดชา" },
|
||||
{ userId: "mock-user-09", userName: "ผู้เชี่ยวชาญ มานะ" },
|
||||
{ userId: "mock-user-10", userName: "ผู้ช่วยเลขานุการ ฝ้าย" },
|
||||
/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server)
|
||||
* ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */
|
||||
export const MOCK_USERS: readonly string[] = [
|
||||
"user1",
|
||||
"user2",
|
||||
"user3",
|
||||
"user4",
|
||||
"user5",
|
||||
];
|
||||
|
||||
export function setLocalUser(user: LocalUser): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้
|
||||
export function getStoredLocalUser(): LocalUser | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as LocalUser;
|
||||
if (typeof parsed.userId === "string" && typeof parsed.userName === "string") {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ข้อมูลเก่าเสียหาย → ถือว่าไม่มีข้อมูล
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLocalUser(): LocalUser {
|
||||
const stored = getStoredLocalUser();
|
||||
if (stored) return stored;
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName }));
|
||||
/** แยกค่า query string ของ cookie oacCase → identity
|
||||
* ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null)
|
||||
* หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว
|
||||
* จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */
|
||||
export function parseOacCase(raw: string | null | undefined): OacUser | null {
|
||||
if (!raw) return null;
|
||||
const params = new URLSearchParams(raw);
|
||||
const userId = params.get("u_userid")?.trim();
|
||||
const userName = params.get("u_userlogin")?.trim();
|
||||
if (!userId || !userName) return null;
|
||||
return { userId, userName };
|
||||
}
|
||||
|
||||
/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง)
|
||||
* mock login แทนเฉพาะ u_userid/u_userlogin */
|
||||
export function buildOacCaseCookie(userId: string, userLogin: string): string {
|
||||
return [
|
||||
`u_userid=${userId}`,
|
||||
`u_userlogin=${userLogin}`,
|
||||
"u_courtid=4",
|
||||
"u_password=",
|
||||
"u_AdminStatus=",
|
||||
"u_perCardNo=5000000000006",
|
||||
"u_perCardNoEncript=1800650010005002003",
|
||||
"u_userType=4",
|
||||
"u_CidAuthen=NNNENNNNNNNNNNNNNNNN",
|
||||
"u_hSecId=NNNNNNNNNNNNNNNNNNNN",
|
||||
"u_username=",
|
||||
"u_courtName=",
|
||||
"u_AuditID=1995227",
|
||||
"u_PsGroupNum=1",
|
||||
"u_PsGroup=11",
|
||||
"u_PsGroupName=",
|
||||
].join("&");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue