eCourt-note/app/note/dev/page.tsx
Your Name d37bc2a3f0 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

297 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Link from "next/link";
import AppNav from "@/components/AppNav";
import DevLoginLinkForm from "@/components/DevLoginLinkForm";
import { devMintEnabled } from "@/lib/note-auth";
export const dynamic = "force-dynamic";
/**
* /note/dev — เครื่องมือทดสอบ (ย้ายมาจากหน้าแรก 2026-10-30 ตอน /note เปลี่ยนเป็น
* landing เรียบ): เปิดเอกสารตัวอย่าง (D14) + คู่มือทางเข้า SSO 2 แบบ + ตัวช่วย mint
* ลิงก์ทดสอบ · **public** (ต้อง mint ลิงก์ได้ตอนยังไม่มี session ไม่งั้นตายก่อนเริ่ม
* — ฉบับ token 2026-10-30: ไม่มี middleware แล้ว ทุกหน้าเป็น shell public ส่วน
* session ตรวจฝั่ง client ผ่าน localStorage) · ตัวอย่างลิงก์เป็น "รูปแบบ + ขั้นตอน"
* เท่านั้น ไม่ mint จริงจากหน้า (secret ต้องอยู่ฝั่ง server) · ไม่ query DB
* · แบนเนอร์ ?sso= และ bootstrap ของ N8 ?fileUrl= อยู่ที่หน้าแรก /note
*/
/** รายการเอกสาร fixed ในโค้ด (D14) — จาก PDF ตัวอย่างที่มีอยู่จริงของแอป
* (public/note/samples/ — ยังไม่ seed กดเปิดแล้ว N8 จะตอบ error เองที่หน้าแรก) */
const SAMPLE_DOCS: Record<string, string[]> = {
"คำสั่งศาล — คดี 01012-672427": [
"01012-672427-1f-690609-0000840223.pdf",
"01012-672427-4f-690609-0000840227.pdf",
],
"คำสั่งศาล — ตัวอย่าง": [
"court_command_11_123 1.pdf",
"court_command_1_2547 1.pdf",
],
};
/** code inline สไตล์เดียวกันทั้งหน้า */
function Code({ children }: { children: React.ReactNode }) {
return (
<code className="rounded bg-ink/[0.05] px-1 py-0.5 text-[13px] text-ink">
{children}
</code>
);
}
/** บล็อกคำสั่ง/URL ตัวอย่าง — อ่านง่ายบนจอแคบ (scroll แนวนอนได้) */
function CodeBlock({ children }: { children: string }) {
return (
<pre className="mt-2 overflow-x-auto rounded-lg border border-line bg-canvas px-3 py-2.5 text-[12.5px] leading-relaxed text-ink">
{children}
</pre>
);
}
export default async function DevToolsPage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
// โหมด credential ของลิงก์ตัวอย่าง — jwt (default) | ticket สลับผ่าน ?sampleMode=
const sampleMode = searchParams.sampleMode === "ticket" ? "ticket" : "jwt";
const mintOn = devMintEnabled();
return (
<>
<AppNav />
<main className="mx-auto max-w-4xl px-4 pb-16 pt-10 sm:px-6">
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
เครื่องมือทดสอบ
</h1>
<p className="mt-2 text-body text-ink-soft">
หน้านี้สำหรับผู้พัฒนาและผู้ทดสอบ — ลองเปิดเอกสารตัวอย่าง
และดูวิธีเข้าระบบผ่านลิงก์ของ e-Court
</p>
{/* ── เปิดเอกสารตัวอย่าง (D14) — A Link Get(new) แบบ /tmpl ─────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">เปิดเอกสารตัวอย่าง</h2>
</div>
<p className="mt-3 text-sm text-ink-soft">
กดชื่อไฟล์แล้วระบบจะสร้างลิงก์แบบเดียวกับที่ e-Court ส่งมาให้
(แบบ{sampleMode === "jwt" ? " JWT" : " ticket"}) แล้วพาเข้าไปอ่านเอกสารนั้น
ทันที — ดูแถบที่อยู่ของ browser จะเห็นการทำงานทีละขั้นจริง ๆ
ตั้งแต่เข้าระบบจนถึงหน้าอ่านเอกสาร
</p>
{mintOn ? (
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="text-ink-soft">รูปแบบลิงก์:</span>
{(["jwt", "ticket"] as const).map((m) => (
<Link
key={m}
href={m === "jwt" ? "/note/dev" : "/note/dev?sampleMode=ticket"}
aria-current={sampleMode === m ? "page" : undefined}
className={`focus-ring rounded-md border px-2.5 py-1 font-medium transition ${
sampleMode === m
? "border-primary bg-primary/[0.08] text-primary"
: "border-line bg-white text-ink-soft hover:border-ink-faint"
}`}
>
{m === "jwt" ? "JWT (ทาง A)" : "ticket (ทาง B)"}
</Link>
))}
</div>
) : (
<p className="mt-3 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
ตอนนี้ลิงก์ด้านล่างเปิดเอกสารแบบธรรมดา (ไม่ได้จำลองการเข้าระบบ) —
เพิ่ม <Code>NOTE_SSO_DEV_MINT=1</Code> ในไฟล์ <Code>.env</Code> แล้วรีสตาร์ท
dev server จะกดแล้วผ่านขั้นตอนเข้าระบบเหมือนของจริงทุกอย่าง
</p>
)}
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
<h3 className="font-display text-subhead text-primary">A Link Get(new)</h3>
{Object.entries(SAMPLE_DOCS).map(([group, files]) => (
<div key={group} className="mt-3">
<p className="text-xs font-medium tracking-wide text-ink-faint">{group}</p>
<ul className="mt-1 space-y-1">
{files.map((file) => (
<li key={file}>
<Link
href={
mintOn
? `/api/note/auth/dev-link?file=${encodeURIComponent(file)}&mode=${sampleMode}`
: `/api/note/files/open?fileUrl=${encodeURIComponent(`/samples/${file}`)}`
}
className="focus-ring rounded text-primary transition hover:underline"
>
{file}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* ── Flow ─────────────────────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">Flow</h2>
</div>
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6 text-center text-sm font-medium leading-relaxed text-ink">
e-Court สร้างลิงก์เข้าใช้ (มีตัวยืนยันตัวตน + ที่อยู่ไฟล์)
<br />↓<br />
ผู้ใช้กดลิงก์ → ระบบตรวจแล้วออก session ให้ (ใช้ได้ 8 ชม.)
<br />↓<br />
เข้าหน้าอ่านเอกสาร — มาร์กข้อความ วาดเส้น คอมเมนต์ คั่นหน้า
<br />↓<br />
บันทึกทันทีทุกครั้งที่แก้ คนอื่นที่เปิดไฟล์เดียวกันเห็นสด ๆ
<br />↓<br />
ดูรวมทั้งหมดได้ที่หน้า &quot;สรุปหมายเหตุ&quot; และ &quot;เอกสารในกลุ่ม&quot;
<br />↓<br />
ระบบอื่น (เช่น e-Court) ดึงข้อมูลไปใช้ต่อได้ผ่าน API
</div>
</section>
{/* ── ทางเข้าระบบผ่านลิงก์ e-Court (คู่มือ 2 ทาง ตามแผน SSO) ────────── */}
<section className="mt-12">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ทางเข้าระบบผ่านลิงก์ e-Court (SSO)
</h2>
</div>
<p className="mt-3 text-sm text-ink-soft">
แอปนี้ไม่มีหน้าล็อกอิน — ผู้ใช้เข้าได้ทางลิงก์ที่ e-Court สร้างให้อย่างเดียว
ลิงก์มี 2 แบบ (ใช้แบบใดแบบหนึ่ง — ห้ามใส่ครบทั้งสองแบบในลิงก์เดียว)
</p>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
{/* ทาง A — JWT */}
<article className="flex flex-col rounded-xl border border-line bg-white p-5">
<div className="flex items-center gap-2">
<h3 className="font-display text-subhead text-ink">ทาง A — ขอ token (JWT)</h3>
<span className="rounded bg-primary/[0.08] px-1.5 py-0.5 text-[11px] font-medium text-primary">
แนะนำ
</span>
</div>
<p className="mt-1.5 text-sm text-ink-soft">
เหมาะเมื่อสร้างลิงก์ให้ผู้ใช้ได้ตอนนั้นเลย (ตอนเปิดหน้า) — คีย์ที่ใช้เซ็น
อยู่ฝั่ง server เท่านั้น ปลอมตัวเป็นผู้อื่นไม่ได้
</p>
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ฝั่ง e-Court เรียก <Code>POST /api/note/auth/token</Code> พร้อมรหัส{" "}
<Code>x-api-key</Code> (ค่าจาก env <Code>DOCVER_API_KEY</Code>) และข้อมูล:
</li>
</ol>
<CodeBlock>{`POST {BASE}/api/note/auth/token
x-api-key: <DOCVER_API_KEY>
{"userid":"<u_userid>","username":"<u_userlogin>",
"fid":"<fileId>","ttl_seconds":300}`}</CodeBlock>
<ol start={2} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ได้ <Code>access_token</Code> กลับมา (อายุ 1–10 นาที) — นำไปใส่ในลิงก์
เป็นพารามิเตอร์ <Code>t</Code> ให้ผู้ใช้กด:
</li>
</ol>
<CodeBlock>{`{BASE}/note
?fileUrl=<urlencode>&typeId=<กลุ่ม ทางเลือก>&t=<access_token>`}</CodeBlock>
<ol start={3} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ผู้ใช้กดลิงก์ → ระบบตรวจแล้วออก session ให้ (เก็บในเครื่องของผู้ใช้
อายุ 8 ชม.) → พาเข้าหน้าอ่านเอกสาร และตัดค่า token ออกจากลิงก์ทันที
</li>
</ol>
<p className="mt-4 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
ข้อควรระวัง: สร้าง token ตอนแสดงหน้าให้ผู้ใช้คนนั้น ๆ เท่านั้น และ{" "}
<strong className="font-medium text-ink-soft">
ห้ามเก็บ token ไว้ในที่อื่น
</strong>{" "}
(อายุสั้นมาก) · token นี้ใช้เข้าแอปนี้อย่างเดียว เอาไปเรียก API
กลุ่ม documents ไม่ได้
</p>
</article>
{/* ทาง B — ticket */}
<article className="flex flex-col rounded-xl border border-line bg-white p-5">
<div className="flex items-center gap-2">
<h3 className="font-display text-subhead text-ink">ทาง B — ticket</h3>
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
สร้างล่วงหน้า
</span>
</div>
<p className="mt-1.5 text-sm text-ink-soft">
เหมาะเมื่อสร้างลิงก์เตรียมไว้ล่วงหน้าได้ ไม่ต้องเรียก API ตอนแสดงหน้า —
ใช้คู่ <Code>ticket_data</Code> + <Code>HmacSha256</Code> ชุดเดียวกับที่ฝัง
ไว้ในไฟล์ .docx ของ add-in
</p>
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
e-Court เตรียม <Code>ticket_data</Code> (JSON มี{" "}
<Code>exp/fid/uid/uname</Code>) แล้วเซ็น <Code>HmacSha256</Code> ด้วยคีย์{" "}
<Code>ADDIN_TICKET_SECRET</Code>
</li>
<li>นำมาใส่ในลิงก์ให้ผู้ใช้กด:</li>
</ol>
<CodeBlock>{`{BASE}/note
?fileUrl=<urlencode>&typeId=<ทางเลือก>
&ticket_data=<JSON urlencode>&HmacSha256=<hex 64 ตัว>`}</CodeBlock>
<ol start={3} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
<li>
ผู้ใช้กดลิงก์ → ระบบตรวจลายเซ็นและวันหมดอายุ → ออก session 8 ชม. →
พาเข้าหน้าอ่านเอกสาร
</li>
</ol>
<p className="mt-4 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
ข้อควรระวัง: ตั้ง <Code>exp</Code> สั้น ๆ (ไม่เกิน ~10 นาที เพราะค่าเหล่านี้
อยู่ในลิงก์) · ค่าผู้ใช้ (<Code>userid/username</Code> ทาง A,{" "}
<Code>uid/uname</Code> ทาง B) ต้องเป็นคนเดียวกับ{" "}
<Code>u_userid/u_userlogin</Code> ของ e-Court — ใส่ผิดคน = ข้อมูล
ผูกกับคนผิด
</p>
</article>
</div>
{/* dev tool — ฟอร์ม mint ลิงก์ (เปิดด้วย env NOTE_SSO_DEV_MINT=1 เท่านั้น:
ตัวช่วยทดสอบ ไม่ใช่ทางเข้าจริง และตายเสมอใน production build) */}
{devMintEnabled() && (
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-5">
<h4 className="font-display text-sm font-semibold text-ink">
สร้างลิงก์ทดสอบเอง (dev)
</h4>
<p className="mt-1.5 text-sm text-ink-soft">
กรอกชื่อผู้ใช้แล้วกดสร้าง — ระบบจะสร้างลิงก์ให้จากคีย์ของตัวเอง
(ค่าลับไม่โผล่ที่หน้าเว็บ) ได้ลิงก์มาแล้วกด &quot;เปิดลิงก์&quot;
เพื่อทดสอบเข้าจริงทั้งสองแบบ
</p>
<div className="mt-4">
<DevLoginLinkForm />
</div>
</div>
)}
{/* dev tool — สคริปต์มือ (ใช้ได้เสมอ ไม่ขึ้นกับ env) */}
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-5">
<h4 className="font-display text-sm font-semibold text-ink">
ทดสอบบนเครื่อง (dev)
</h4>
<p className="mt-1.5 text-sm text-ink-soft">
สร้างลิงก์เองบนเครื่องโดยไม่ต้องรอ e-Court — สคริปต์อ่านค่าจากไฟล์{" "}
<Code>.env</Code> แล้วพิมพ์ลิงก์เต็มให้ก๊อปไปเปิดใน browser:
</p>
<CodeBlock>{`# ทาง A (default)
node scripts/make-login-link.mjs --uid oom --uname "สรวิชญ์" --fid F123 \\
--fileUrl /samples/01012-672427-1f-690609-0000840223.pdf \\
--base http://localhost:3000/docedit
# ทาง B
node scripts/make-login-link.mjs --mode ticket --uid oom --uname "สรวิชญ์" --fid F123 \\
--fileUrl /samples/01012-672427-1f-690609-0000840223.pdf \\
--base http://localhost:3000/docedit`}</CodeBlock>
<p className="mt-3 text-xs text-ink-soft">
ถ้าลิงก์หมดอายุหรือไม่ถูกต้อง ระบบจะพากลับหน้าแรกพร้อมข้อความบอกสาเหตุ —
สร้างลิงก์ใหม่แล้วกดอีกครั้งได้เลย
</p>
</div>
</section>
</main>
</>
);
}