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:
Your Name 2026-10-30 18:05:36 +07:00
parent b1449ab668
commit d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions

View file

@ -113,7 +113,6 @@ import {
type Rect,
type Size,
} from "@/utils/coordinates";
import type { OacUser } from "@/utils/user";
import {
MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS,
@ -1315,13 +1314,17 @@ export default function PDFViewer({
initialBookmarks,
initialReplies,
user,
token,
}: {
file: { id: string; title: string; pdfUrl: string };
initialAnnotations: AnnotationDTO[];
initialBookmarks: BookmarkDTO[];
initialReplies: ReplyDTO[];
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */
user: OacUser;
/** identity จาก access_token ของ session (AnnotateView การันตีให้พร้อมกับ token
* — ไม่มีช่วง null หลัง mount · ฉบับ token 2026-10-30 แทน cookie oacCase) */
user: { userId: string; userName: string };
/** access_token ส่งเข้าทุก Server Action — identity แกะฝั่ง server */
token: string;
}) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
@ -2152,7 +2155,7 @@ export default function PDFViewer({
type: AnnotationType,
data: AnnotationData,
) {
if (!user) return;
const now = new Date().toISOString();
const temp: AnnotationDTO = {
id: crypto.randomUUID(),
@ -2172,19 +2175,17 @@ export default function PDFViewer({
await startTransition(async () => {
applyAnnotation({ kind: "add", row: temp });
try {
const saved = await createAnnotation({
const saved = await createAnnotation(token, {
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
});
if (pending.cancelled) {
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
await deleteAnnotation(saved.id, user.userId);
await deleteAnnotation(token, saved.id);
return;
}
setAnnotations((prev) =>
@ -2205,15 +2206,13 @@ export default function PDFViewer({
let currentId = saved.id;
pushHistory({
undo: async () => {
await deleteAnnotation(currentId, user.userId);
await deleteAnnotation(token, currentId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
},
redo: async () => {
const re = await createAnnotation({
const re = await createAnnotation(token, {
fileId: file.id,
pageIndex,
userId: user.userId,
userName: user.userName,
type,
data,
});
@ -2233,7 +2232,7 @@ export default function PDFViewer({
}
async function handleDeleteAnnotation(id: string) {
if (!user) return;
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
@ -2251,18 +2250,16 @@ export default function PDFViewer({
await startTransition(async () => {
applyAnnotation({ kind: "remove", id });
try {
await deleteAnnotation(id, user.userId);
await deleteAnnotation(token, id);
setAnnotations((prev) => prev.filter((a) => a.id !== id));
if (target) {
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
let currentId: string | null = null;
pushHistory({
undo: async () => {
const re = await createAnnotation({
const re = await createAnnotation(token, {
fileId: file.id,
pageIndex: target.pageIndex,
userId: target.userId,
userName: target.userName,
type: target.type,
data: target.data,
});
@ -2273,7 +2270,7 @@ export default function PDFViewer({
},
redo: async () => {
if (!currentId) return;
await deleteAnnotation(currentId, user.userId);
await deleteAnnotation(token, currentId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
},
});
@ -2300,7 +2297,7 @@ export default function PDFViewer({
* await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */
function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> {
return new Promise((resolve) => {
if (!user) return resolve();
const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
bumpPreviewClear();
@ -2310,15 +2307,15 @@ export default function PDFViewer({
kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() },
});
const saved = await updateAnnotation(id, user.userId, data);
const saved = await updateAnnotation(token, id, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({
undo: async () => {
const up = await updateAnnotation(id, user.userId, before);
const up = await updateAnnotation(token, id, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
redo: async () => {
const up = await updateAnnotation(id, user.userId, data);
const up = await updateAnnotation(token, id, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
},
});
@ -2392,7 +2389,7 @@ export default function PDFViewer({
},
});
try {
const saved = await setAnnotationResolved(id, resolved);
const saved = await setAnnotationResolved(token, id, resolved);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
} catch (err) {
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
@ -2407,12 +2404,10 @@ export default function PDFViewer({
annotationId: string,
message: string,
): Promise<boolean> {
if (!user) return false;
try {
const saved = await createReply({
const saved = await createReply(token, {
annotationId,
userId: user.userId,
userName: user.userName,
message,
});
setReplies((prev) =>
@ -2427,9 +2422,9 @@ export default function PDFViewer({
}
async function handleDeleteReply(id: string) {
if (!user) return;
try {
await deleteReply(id, user.userId);
await deleteReply(token, id);
setReplies((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
console.error("ลบคำตอบไม่สำเร็จ", err);
@ -2564,15 +2559,13 @@ export default function PDFViewer({
async function handleSaveBookmark() {
const c = bookmarkComposer;
if (!c || !user || bookmarkPendingRef.current) return;
if (!c || bookmarkPendingRef.current) return;
bookmarkPendingRef.current = true;
setBookmarkSaving(true);
try {
const saved = await createBookmark({
const saved = await createBookmark(token, {
fileId: file.id,
pageIndex: c.pageIndex,
userId: user.userId,
userName: user.userName,
title: c.title.trim() || `หน้า ${c.pageIndex + 1}`,
yOffset: c.yOffset,
parentId: c.parentId,
@ -2597,16 +2590,14 @@ export default function PDFViewer({
/** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */
async function handleAddSubBookmark(parentId: string) {
if (!user || bookmarkPendingRef.current) return;
if (bookmarkPendingRef.current) return;
const pos = currentViewPosition();
if (!pos) return;
bookmarkPendingRef.current = true;
try {
const saved = await createBookmark({
const saved = await createBookmark(token, {
fileId: file.id,
pageIndex: pos.pageIndex,
userId: user.userId,
userName: user.userName,
title: `หน้า ${pos.pageIndex + 1}`,
yOffset: pos.yOffset,
parentId,
@ -2626,9 +2617,9 @@ export default function PDFViewer({
id: string,
patch: { title?: string; note?: string | null; parentId?: string | null },
) {
if (!user) return;
try {
const saved = await updateBookmark({ id, userId: user.userId, ...patch });
const saved = await updateBookmark(token, { id, ...patch });
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
} catch (err) {
console.error("แก้ไข bookmark ไม่สำเร็จ", err);
@ -2636,11 +2627,11 @@ export default function PDFViewer({
}
async function handleDeleteBookmark(id: string) {
if (!user) return;
await startTransition(async () => {
applyBookmark({ kind: "remove", id });
try {
await deleteBookmark(id, user.userId);
await deleteBookmark(token, id);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
console.error("ลบ bookmark ไม่สำเร็จ", err);
@ -3283,6 +3274,7 @@ export default function PDFViewer({
bookmarks={optimisticBookmarks}
fileId={file.id}
userId={user?.userId ?? ""}
token={token}
userFilter={userFilter}
users={users}
onUserFilterChange={setUserFilter}
@ -4483,7 +4475,7 @@ export default function PDFViewer({
void handleDeleteReply(id);
}}
onStrokeEnd={(points, widths) => {
if (!user) return;
void handleCreateAnnotation(i, "DRAW", {
strokeColor: drawStyle.color,
strokeWidth: drawStyle.strokeWidthPct,
@ -4494,11 +4486,11 @@ export default function PDFViewer({
});
}}
onShapeEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "SHAPE", data);
}}
onRedactionEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "REDACTION", data);
}}
onTextLayerRendered={handleTextLayerRendered}