เปลี่ยน 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>
151 lines
5.8 KiB
TypeScript
151 lines
5.8 KiB
TypeScript
"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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|