eCourt-note/components/NoteHome.tsx

291 lines
14 KiB
TypeScript
Raw Normal View History

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>
2026-10-30 18:05:36 +07:00
"use client";
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา
// ที่ /note) = แลกเป็น access_token ที่ POST /api/note/auth/session แล้วเก็บ
// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
Bookmark,
ExternalLink,
FileText,
Highlighter,
MessagesSquare,
} from "lucide-react";
import AppNav from "@/components/AppNav";
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
import { saveNoteSession } from "@/lib/note-session";
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>
2026-10-30 18:05:36 +07:00
import { withBasePath } from "@/utils/base-path";
type Phase = "boot" | "exchanging" | "ready";
type SessionResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
userid?: string;
username?: string;
fileId?: string;
fileError?: string;
reason?: string;
};
export default function NoteHome() {
const router = useRouter();
const [phase, setPhase] = useState<Phase>("boot");
const [banner, setBanner] = useState<SsoBanner | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
// StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ
const bootedRef = useRef(false);
useEffect(() => {
if (bootedRef.current) return;
bootedRef.current = true;
const url = new URL(window.location.href);
const stripParams = () => {
url.search = "";
window.history.replaceState(null, "", url.toString());
};
const fail = (reason: "expired" | "invalid" | "error") => {
setBanner(ssoBanner(reason));
setPhase("ready");
};
const parsed = parseSsoLinkParams(url);
const hasCredentialKey =
url.searchParams.get("t") !== null ||
url.searchParams.get("ticket_data") !== null ||
url.searchParams.get("HmacSha256") !== null;
if (parsed.ok) {
setPhase("exchanging");
const body: Record<string, string> = {};
if (parsed.credential.kind === "jwt") {
body.t = parsed.credential.token;
} else {
body.ticket_data = parsed.credential.ticketData;
body.HmacSha256 = parsed.credential.hmacHex;
}
if (parsed.fileUrl !== undefined) body.fileUrl = parsed.fileUrl;
if (parsed.typeId !== undefined) body.typeId = parsed.typeId;
// credential ต้องออกจาก URL ทันทีที่รู้ผล — แต่ strip ก่อนยิงไม่ได้ (reload
// กลางคัน = ลิงก์ตาย) จึงกันค้างด้วย timeout: เครือข่ายนิ่งเกิน 45 วิ = ตัดจบ
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
void (async () => {
try {
const res = await fetch(withBasePath("/api/note/auth/session"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const data = (await res.json().catch(() => null)) as SessionResponse | null;
stripParams();
if (
!res.ok ||
data === null ||
data.access_token === undefined ||
data.userid === undefined ||
data.username === undefined ||
data.expires_in === undefined
) {
fail(
res.status === 401 && data?.reason === "expired"
? "expired"
: res.status === 401
? "invalid"
: "error",
);
return;
}
const { access_token, userid, username, expires_in, fileId, fileError } = data;
saveNoteSession({
token: access_token,
userid,
username,
expMs: Date.now() + expires_in * 1000,
});
if (fileError !== undefined) setFileError(fileError);
if (fileId !== undefined) {
// เข้า viewer เลย — คงสถานะ exchanging ให้ spinner อยู่จนหน้า viewer มาแทน
router.replace(`/note/annotate/${fileId}`);
return;
}
setPhase("ready");
} catch {
stripParams();
fail("error");
} finally {
window.clearTimeout(timeoutId);
}
})();
return;
}
if (hasCredentialKey) {
// มี credential ใน URL แต่รูปไม่ผ่าน (ticket ครึ่งเดียว/ยาวเกิน cap/สองชุดพร้อม
// กัน) — ตัดทิ้ง + แจ้งผู้ใช้ ไม่ปล่อยค่าเน่าค้างอยู่ใน history (D11)
stripParams();
fail("invalid");
return;
}
// ไม่มี credential — หน้าข้อมูลระบบปกติ (แบนเนอร์ ?sso= ของลิงก์เก่า ·
// fileError จาก GET /api/note/files/open ตอนเปิดไฟล์ตัวอย่างไม่สำเร็จ)
setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined));
const openFileError = url.searchParams.get("fileError");
if (openFileError !== null && openFileError !== "") setFileError(openFileError);
setPhase("ready");
}, [router]);
return (
<>
<AppNav />
<main className="mx-auto max-w-4xl px-4 pb-16 pt-12 sm:px-6">
{phase !== "ready" ? (
<p className="mt-2 text-body text-ink-soft" role="status">
{phase === "exchanging"
? "กำลังเข้าสู่ระบบผ่านลิงก์ของ e-Court…"
: "กำลังโหลด…"}
</p>
) : (
<>
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบอ่านและทำเครื่องหมายเอกสารคดีของ e-Court — มาร์กข้อความ วาดเส้น
ใส่ความเห็น คั่นหน้า และสรุปหมายเหตุทั้งคดีได้ในที่เดียว
โดยไฟล์ต้นฉบับไม่ถูกแก้ไข
</p>
{banner && (
<div
role="status"
className={`mt-6 rounded-lg border p-4 text-sm ${
banner.intent === "error"
? "border-error/25 bg-error/5 text-error-ink"
: "border-warning/30 bg-warning/5 text-warning-ink"
}`}
>
{banner.text}
</div>
)}
{fileError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {fileError}
</div>
)}
{/* ── ระบบนี้ทำอะไรได้ ────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ระบบนี้ทำอะไรได้
</h2>
</div>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
{[
{
icon: Highlighter,
title: "มาร์กและวาดบนเอกสาร",
detail:
"มาร์กข้อความไฮไลต์ได้หลายแบบ วาดเส้นด้วยปากกา วาดรูปทรง " +
"ใส่กล่องข้อความและตรายาง รวมถึงปิดบังข้อมูลที่ละเอียดอ่อน " +
"— ย้าย แก้ไข ย่อ-ขยายได้หลังวาง พร้อมปุ่มเรียกคืน (undo/redo)",
},
{
icon: MessagesSquare,
title: "ความเห็นและทำงานร่วมกัน",
detail:
"หมุดความเห็นพร้อมตอบกลับเป็นเธรด ทำเครื่องหมาย “จัดการแล้ว” " +
"เมื่อเสร็จ และเห็นการเปลี่ยนแปลงของทุกคนแบบสด ๆ " +
"ในไฟล์เดียวกันได้ทันที",
},
{
icon: Bookmark,
title: "คั่นหน้าและสรุปคดี",
detail:
"คั่นหน้าเป็นหัวข้อซ้อนกันได้ 3 ระดับพร้อมโน้ตย่อย ค้นหาทั้งไฟล์ " +
"แล้วดูรวมทั้งหมดที่หน้า “สรุปหมายเหตุ” — พิมพ์ออกมาเป็น " +
"เอกสารสรุปของคดีได้",
},
{
icon: FileText,
title: "เอกสารจาก e-Court",
detail:
"เปิด PDF และรูปภาพ (รวมไฟล์ TIFF หลายหน้า) จากลิงก์ของ e-Court " +
"จัดกลุ่มตามคดี และส่งออกเป็น PDF ที่ฝังมาร์กทุกอย่างไว้ในตัว " +
"— ไฟล์ต้นฉบับต้นทางไม่ถูกแตะต้อง",
},
].map(({ icon: Icon, title, detail }) => (
<div
key={title}
className="flex gap-4 rounded-xl border border-line bg-white p-6"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Icon className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">{title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{detail}
</p>
</div>
</div>
))}
</div>
</section>
{/* ── วิธีเข้าใช้งาน ─────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
วิธีเข้าใช้งาน
</h2>
</div>
<div className="mt-5 flex max-w-xl gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<ExternalLink className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">
เข้าใช้งานผ่านลิงก์ของ e-Court
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
ระบบนี้ไม่มีหน้าล็อกอิน — เปิดเอกสารจากระบบ e-Court
แล้วระบบพาเข้าหน้าอ่านเอกสารโดยอัตโนมัติ ใช้งานต่อได้ 8 ชม.
ต่อการเข้าหนึ่งครั้ง
</p>
</div>
</div>
<p className="mt-8 text-sm text-ink-soft">
ผู้พัฒนา/ผู้ทดสอบ:{" "}
<Link
href="/note/dev"
className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline"
>
เครื่องมือทดสอบทางเข้าและเอกสารตัวอย่าง →
</Link>
</p>
</section>
</>
)}
</main>
</>
);
}