เปลี่ยน 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>
294 lines
15 KiB
TypeScript
294 lines
15 KiB
TypeScript
"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 { readNoteSession, saveNoteSession, type NoteSession } from "@/lib/note-session";
|
|
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);
|
|
const [session, setSession] = useState<NoteSession | 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));
|
|
setSession(readNoteSession());
|
|
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;
|
|
}
|
|
setSession(readNoteSession());
|
|
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);
|
|
setSession(readNoteSession());
|
|
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'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>
|
|
</>
|
|
);
|
|
}
|