eCourt-note/components/DevLoginLinkForm.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

160 lines
7.3 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.

"use client";
import { useState, type FormEvent } from "react";
import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม mint ลิงก์ SSO ทดสอบบนหน้า /note/dev (dev-only — route เปิดด้วย env
* NOTE_SSO_DEV_MINT=1 เท่านั้น, คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note") — server
* คำนวณ token/ticket ด้วย secret ของตัวเองแล้วคืน URL เต็ม ค่าลับไม่ผ่านหน้า
* เบราว์เซอร์ · ได้ลิงก์มา = คลิกเข้าจริงตาม flow ของทางนั้น (A/B)
*/
export default function DevLoginLinkForm() {
const [mode, setMode] = useState<"jwt" | "ticket">("jwt");
const [userid, setUserid] = useState("oom");
const [username, setUsername] = useState("สรวิชญ์");
const [fid, setFid] = useState("F123");
const [ttl, setTtl] = useState(300);
const [fileUrl, setFileUrl] = useState("/samples/01012-672427-1f-690609-0000840223.pdf");
const [typeId, setTypeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
setUrl(null);
setCopied(false);
try {
const res = await fetch(withBasePath("/api/note/auth/dev-link"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode,
userid,
username,
fid,
ttl_seconds: ttl,
...(fileUrl.trim() !== "" ? { fileUrl: fileUrl.trim() } : {}),
...(typeId.trim() !== "" ? { typeId: typeId.trim() } : {}),
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as { url: string };
setUrl(data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "สร้างลิงก์ไม่สำเร็จ");
} finally {
setBusy(false);
}
};
const inputCls =
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
const labelCls = "text-xs font-medium text-ink-soft";
return (
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-mode">ทางเข้า</label>
<select
id="devlink-mode"
value={mode}
onChange={(e) => setMode(e.target.value === "ticket" ? "ticket" : "jwt")}
className={inputCls}
>
<option value="jwt">ทาง A — JWT</option>
<option value="ticket">ทาง B — ticket</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-ttl">อายุ (วินาที 60–600)</label>
<input
id="devlink-ttl"
type="number"
min={60}
max={600}
value={ttl}
onChange={(e) => setTtl(Number(e.target.value))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-uid">userid / uid</label>
<input id="devlink-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-uname">username / uname</label>
<input id="devlink-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-fid">fid (fileId ของ e-Court)</label>
<input id="devlink-fid" value={fid} onChange={(e) => setFid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-typeid">typeId (ทางเลือก)</label>
<input id="devlink-typeid" value={typeId} onChange={(e) => setTypeId(e.target.value)} className={inputCls} placeholder="ไม่กรอก = ไม่มี" />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="devlink-fileurl">fileUrl (ไม่กรอกก็ได้ — ไฟล์ตัวอย่าง /samples/… หรือลิงก์ https ที่อนุญาต)</label>
<input id="devlink-fileurl" value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className={inputCls} placeholder="ไม่กรอก = เข้าหน้าแรกอย่างเดียว" />
</div>
<div className="sm:col-span-2">
<button
type="submit"
disabled={busy}
className="focus-ring inline-flex h-9 items-center rounded-md border border-line bg-white px-4 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint disabled:pointer-events-none disabled:opacity-50"
>
{busy ? "กำลังสร้าง…" : "สร้างลิงก์ทดสอบ"}
</button>
</div>
{error && (
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
{error}
</p>
)}
{url && (
<div className="sm:col-span-2 rounded-lg border border-line bg-canvas p-3">
<p className="text-xs font-medium text-ink-soft">
ลิงก์พร้อมใช้ (อายุ {ttl} วินาที) — กดเพื่อทดสอบเข้าจริง:
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<a
href={url}
className="focus-ring inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover"
>
เปิดลิงก์
</a>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
/* clipboard โดน block — คัดลอกจากกล่องข้อความด้านล่างเอง */
}
}}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line bg-white px-3 text-xs font-medium text-ink-soft transition hover:border-ink-faint"
>
{copied ? "คัดลอกแล้ว" : "คัดลอก URL"}
</button>
</div>
<p className="mt-2 break-all rounded border border-line bg-white px-2.5 py-2 text-[12px] leading-relaxed text-ink-soft">
{url}
</p>
</div>
)}
</form>
);
}