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
151
components/pdf/AnnotateView.tsx
Normal file
151
components/pdf/AnnotateView.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
// client gate ของหน้า /note/annotate/[id] (ฉบับ token 2026-10-30) — เดิมเป็น server
|
||||
// component อ่าน identity จาก cookie ผ่าน getSessionUser + query DB ตอน render:
|
||||
// ตอนนี้หน้าเป็น shell public ฝั่ง server (แค่ตรวจ UUID) ส่วนข้อมูลเริ่มต้น
|
||||
// (file + annotations + bookmarks + replies) ดึงผ่าน GET /api/note/files/[id]/
|
||||
// bootstrap พร้อม Bearer หลังเห็น token ใน localStorage — ไม่มี/หมดอายุ = หน้า
|
||||
// gate "กลับ e-Court กดลิงก์ใหม่" (คู่ขนาน sso=required ของรอบ middleware)
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
|
||||
import { ssoBanner } from "@/lib/note-auth";
|
||||
import {
|
||||
clearNoteSession,
|
||||
noteAuthFetch,
|
||||
readNoteSession,
|
||||
type NoteSession,
|
||||
} from "@/lib/note-session";
|
||||
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
|
||||
import PDFViewer from "@/components/pdf/PDFViewer";
|
||||
|
||||
type BootstrapData = {
|
||||
file: { id: string; title: string; pdfUrl: string };
|
||||
annotations: AnnotationDTO[];
|
||||
bookmarks: BookmarkDTO[];
|
||||
replies: ReplyDTO[];
|
||||
user: { userId: string; userName: string };
|
||||
};
|
||||
|
||||
type Phase = "boot" | "loading" | "no-session" | "error" | "ready";
|
||||
|
||||
const BTN_CLS =
|
||||
"focus-ring inline-flex h-10 items-center rounded-md bg-primary px-5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow";
|
||||
|
||||
function GateScreen({ title }: { title: string }) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink"
|
||||
>
|
||||
{ssoBanner("required")!.text}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-ink-soft">{title}</p>
|
||||
<Link href="/note" className={`${BTN_CLS} mt-4`}>
|
||||
กลับหน้าหลัก
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnnotateView({ fileId }: { fileId: string }) {
|
||||
const [phase, setPhase] = useState<Phase>("boot");
|
||||
const [data, setData] = useState<BootstrapData | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const session: NoteSession | null = readNoteSession();
|
||||
if (session === null) {
|
||||
setPhase("no-session");
|
||||
return;
|
||||
}
|
||||
setToken(session.token);
|
||||
setPhase("loading");
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await noteAuthFetch(`/api/note/files/${fileId}/bootstrap`);
|
||||
if (res.status === 401) {
|
||||
// token หมดอายุ/ถูกเพิกถิอกลางวัน — ล้างทิ้งแล้วเป็น gate เหมือนไม่เคยเข้า
|
||||
clearNoteSession();
|
||||
if (!cancelled) setPhase("no-session");
|
||||
return;
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| BootstrapData
|
||||
| { error?: string }
|
||||
| null;
|
||||
if (!res.ok || body === null || !("file" in body)) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
(body as { error?: string } | null)?.error ??
|
||||
`โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`,
|
||||
);
|
||||
setPhase("error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
setData(body);
|
||||
setPhase("ready");
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof Error && err.message === "no-session") {
|
||||
// session หมดพอดีระหว่างต้น effect กับตอน fetch — เป็น gate ไม่ใช่ network error
|
||||
clearNoteSession();
|
||||
setPhase("no-session");
|
||||
return;
|
||||
}
|
||||
setError("โหลดเอกสารไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วลองใหม่อีกครั้ง");
|
||||
setPhase("error");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
|
||||
<DebugConsole />
|
||||
{phase === "no-session" && (
|
||||
<GateScreen title="ยังไม่มี session ของแอปนี้บนเบราว์เซอร์ หรือ session หมดอายุแล้ว (อายุ 8 ชม.)" />
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-error/25 bg-error/10 p-4 text-sm text-error-ink"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
<Link href="/note" className={`${BTN_CLS} mt-4`}>
|
||||
กลับหน้าหลัก
|
||||
</Link>
|
||||
</main>
|
||||
)}
|
||||
{(phase === "boot" || phase === "loading") && (
|
||||
<main className="flex min-h-dvh items-center justify-center">
|
||||
<p className="text-sm text-ink-soft" role="status">
|
||||
กำลังโหลดเอกสาร…
|
||||
</p>
|
||||
</main>
|
||||
)}
|
||||
{phase === "ready" && data !== null && token !== null && (
|
||||
<PDFViewer
|
||||
file={data.file}
|
||||
initialAnnotations={data.annotations}
|
||||
initialBookmarks={data.bookmarks}
|
||||
initialReplies={data.replies}
|
||||
user={data.user}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue