feat(sso): switch note session from cookies to token

เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น
access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30):

- POST /api/note/auth/session แลก credential ของลิงก์ e-Court
  (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม.
  พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid)
- token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer
  ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก
  และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client
- หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note,
  AnnotateView + GET /api/note/files/[id]/bootstrap)
- ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout,
  GET /api/note/auth/e-court, entry /note/annotate?fileUrl=
- ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production
- GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน
  เครื่อง /samples เท่านั้น)
- หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง
  เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง
  ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ
- แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า,
  bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก
  จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose
  ก่อน exp ของ login-token, audit log strip อักขระควบคุม
- เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example

ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright
(SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-30 18:05:36 +07:00
parent b1449ab668
commit d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions

View file

@ -1,236 +1,13 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { asc } from "drizzle-orm";
import { FileImage, FileText, Folder } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
import AppNav from "@/components/AppNav";
import EcourtLinkForm from "@/components/EcourtLinkForm";
import UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { formatThaiDate } from "@/utils/date-format";
import { getSessionUser } from "@/lib/session";
import { registerExternalFile } from "@/lib/external-import";
import { firstSearchParam } from "@/utils/external-file";
import NoteHome from "@/components/NoteHome";
export const dynamic = "force-dynamic";
type FileRow = typeof files.$inferSelect;
/** badge ชนิดไฟล์ต่อรายการ (N3) */
function kindBadge(pdfUrl: string): string {
switch (imageFormatFromUrl(pdfUrl)) {
case "jpeg":
return "JPG";
case "png":
return "PNG";
case "tiff":
return "TIFF";
default:
return "PDF";
}
}
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
function FileCard({ file }: { file: FileRow }) {
return (
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
<div className="flex items-start justify-between gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
{imageFormatFromUrl(file.pdfUrl) ? (
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
) : (
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
)}
</div>
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
{kindBadge(file.pdfUrl)}
</span>
</div>
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
{file.title}
</span>
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
<span className="mt-1 block text-caption text-ink-soft">
{formatThaiDate(file.createdAt, true)}
</span>
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
<div className="mt-5 flex flex-1 items-end justify-end">
<Link
href={`/note/annotate/${file.id}`}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
Annotate
</Link>
</div>
</li>
);
}
export default async function HomePage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
const user = getSessionUser();
if (!user) redirect("/note/login");
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
let registerError: string | null = null;
const fileUrl = firstSearchParam(searchParams.fileUrl);
if (fileUrl) {
const result = await registerExternalFile(
fileUrl,
firstSearchParam(searchParams.fileId),
firstSearchParam(searchParams.typeId),
);
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
registerError = result.error;
}
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
const grouped = new Map<string, FileRow[]>();
const ungrouped: FileRow[] = [];
for (const f of rows) {
if (f.typeId) {
const list = grouped.get(f.typeId);
if (list) list.push(f);
else grouped.set(f.typeId, [f]);
} else {
ungrouped.push(f);
}
}
const sections = Array.from(grouped.entries()).sort(
(a, b) =>
b[1][b[1].length - 1].createdAt.getTime() -
a[1][a[1].length - 1].createdAt.getTime(),
);
return (
<>
<AppNav userName={user.userName} />
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
summary-page ของหน้าสรุป) */}
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
{registerError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
</div>
)}
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
ฟอร์มอัปโหลด) */}
<section className="mt-12">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
เปิดเอกสารจากลิงก์ e-Court
</h2>
</div>
<div className="mt-5 max-w-xl">
<EcourtLinkForm />
</div>
</section>
<section className="mt-12">
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-caption tabular-nums text-ink-soft">
{rows.length} ไฟล์
</span>
)}
</div>
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
<div className="mt-5 max-w-xl">
<UploadFileForm />
</div>
{rows.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-ink-soft">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
) : (
<>
{sections.map(([typeId, list]) => (
<section key={typeId} className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<Folder
aria-hidden
className="h-4 w-4 shrink-0 text-ink-soft"
/>
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
{typeId}
</h3>
<span className="text-caption tabular-nums text-ink-soft">
{list.length} ไฟล์
</span>
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
<Link
href={`/note/groups/${encodeURIComponent(typeId)}`}
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
>
เอกสารในกลุ่ม
</Link>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{list.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
))}
{ungrouped.length > 0 && (
<section className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
<span className="text-caption tabular-nums text-ink-soft">
{ungrouped.length} ไฟล์
</span>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{ungrouped.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
)}
</>
)}
</section>
</main>
</>
);
/**
* หน้าแรก = ข้อมูลเกี่ยวกับระบบ + bootstrap ของลิงก์ e-Court (2026-10-30 — เนื้อหา
* ทั้งหมดอยู่ใน client component NoteHome: ระบบทำอะไรได้ / วิธีเข้าใช้ และตัว
* แลก token ของลิงก์ e-Court ที่ชี้ตรงมา /note) · public — คนไม่มี session อ่าน
* ข้อมูลระบบได้ตาม D15 เดิม ส่วนเครื่องมือทดสอบอยู่ /note/dev
*/
export default function HomePage() {
return <NoteHome />;
}