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:
parent
b1449ab668
commit
d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions
294
components/NoteHome.tsx
Normal file
294
components/NoteHome.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue