diff --git a/CLAUDE.md b/CLAUDE.md index f5ccb40..7ea752d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ - Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`) - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) -- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — ปัจจุบัน auth + API เป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) +- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = e-Court pass-through (token/owner ใน custom properties ของไฟล์) ส่วน API ยังเป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) - mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) @@ -41,7 +41,7 @@ scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คั scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) -middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) @@ -54,13 +54,9 @@ lib/document-versioning.ts ← docver pure validation (ห้าม import อ lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) -wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) +wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -<<<<<<< HEAD -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) -======= -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank) ->>>>>>> 6daee1585199721ba931b2bf4fe55277cb7c304a +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = e-Court pass-through ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — surface เหลือ signInForDemo (dev-only) / signInFromDocument / getAccessToken / getUsername; ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลย ไม่มี = phase `no-access` — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ; apiClient โดน 401 = AuthRequiredError → no-access เลย token ต่ออายุไม่ได้) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -92,8 +88,8 @@ app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PD app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) -components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) -components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** +components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + ตั้ง session mock (`signInForDemo`) + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง; เส้นทางจริงไม่มี demo จะจบที่หน้า no-access เพราะอ่าน properties ไม่ได้) +components/wordaddin/TaskpaneApp.tsx ← state machine checking/no-access/untracked/tracking (ไม่มี login — ยกเลิก Keycloak 2026-10-27: detect อ่าน token/owner จากไฟล์ผ่าน signInFromDocument ไม่มี = no-access · AuthRequiredError จาก flow ก็ไป no-access) + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (เปลี่ยนแค่ JSX → Genesis + ถอดหน้า login/ปุ่มออกจากระบบ — header เหลือ chip username เฉย ๆ) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index 5191a79..1b71ed2 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -3,7 +3,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react"; +import { FileUp, GitCommitHorizontal } from "lucide-react"; import { autoSaveService } from "@/wordaddin/services/autoSaveService"; import * as authService from "@/wordaddin/services/authService"; @@ -23,22 +23,25 @@ import TemplateToolsPanel from "./TemplateToolsPanel"; import VersionHistoryPanel from "./VersionHistoryPanel"; /** - * Task pane หลัก — state machine ตาม Phase 5: - * checking → (ไม่มี token) signed-out - * → (มี token + ไม่ใช่ tracked document) untracked - * → (มี token + tracked document) tracking - * พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว - * เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, + * Task pane หลัก — state machine: + * checking → (ไม่มี token/owner ในไฟล์) no-access + * → (มี token/owner + ไม่ใช่ tracked document) untracked + * → (มี token/owner + tracked document) tracking + * ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ e-Court + * pass-through: `detect()` อ่าน token/owner จาก custom properties แล้วเรียก + * `authService.signInFromDocument` ผ่านเลย + * พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path, + * JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login, * เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง) * - * กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9) + * กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court * error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection * - * FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder + * FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder * กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า */ -type Phase = "checking" | "signed-out" | "untracked" | "tracking"; +type Phase = "checking" | "no-access" | "untracked" | "tracking"; /** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls * (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */ @@ -108,8 +111,8 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); - const [signingIn, setSigningIn] = useState(false); - const [loginError, setLoginError] = useState(null); + /** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */ + const [noAccessNotice, setNoAccessNotice] = useState(null); const [starting, setStarting] = useState(false); const [saving, setSaving] = useState(false); @@ -135,15 +138,16 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => /** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */ const [hasUncommitted, setHasUncommitted] = useState(false); - /** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */ + /** AuthRequiredError (ไม่มี token / 401) → หน้า no-access — token จากไฟล์ต่ออายุไม่ได้ + * ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */ const handleAuthRequired = useCallback((error: unknown): boolean => { if (error instanceof AuthRequiredError) { - setPhase("signed-out"); + setPhase("no-access"); setActiveTab("versioning"); setDocumentId(null); setDocumentTemplateId(null); setVersions([]); - setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); + setNoAccessNotice("token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง"); return true; } return false; @@ -166,32 +170,31 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => [handleAuthRequired], ); - /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history + /** ตรวจตอนเปิด task pane (FR-1): token/owner ในไฟล์ → SavedByAddIn → history * FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked) * ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo) - * e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */ + * e-Court pass-through — auth ทางเข้าเดียว: ไฟล์ที่ stamp token/owner มา = ผ่านเข้าเลย + * ไม่มีขั้นตอน login (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */ const detect = useCallback(async () => { setPhase("checking"); setBootError(null); try { let properties: metadataService.TrackedDocumentProperties | null = null; - if (!(await authService.getAccessToken())) { - // e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login - // (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม) - try { - properties = await metadataService.readTrackedProperties(); - } catch { - properties = null; - } - if ( - !properties || - !authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner }) - ) { - setPhase("signed-out"); - return; - } + try { + properties = await metadataService.readTrackedProperties(); + } catch { + properties = null; + } + if ( + !properties || + !authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner }) + ) { + setNoAccessNotice( + "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น", + ); + setPhase("no-access"); + return; } - authService.startAutoRefresh(); setUsername(authService.getUsername() ?? null); properties ??= await metadataService.readTrackedProperties(); @@ -217,7 +220,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => void detect(); }, [demo, detect]); - /** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี) + /** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี) * ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */ useEffect(() => { autoSaveService.onError = (error) => @@ -235,34 +238,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => }; }, [demo]); - const handleSignIn = async () => { - setSigningIn(true); - setLoginError(null); - try { - const result = await authService.login(); - if (result.ok) { - await detect(); - } else { - setLoginError(result.error); - } - } catch (error) { - setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ")); - } finally { - setSigningIn(false); - } - }; - - const handleSignOut = () => { - authService.signOut(); - setUsername(null); - setDocumentId(null); - setDocumentTemplateId(null); - setVersions([]); - setLoginError(null); - setPhase("signed-out"); - setActiveTab("versioning"); - }; - /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */ const handleSaveOnly = async () => { setSaveBusy(true); @@ -408,18 +383,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
e-Court Documents Versioning - {phase !== "signed-out" && phase !== "checking" && username ? ( - /* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */ - + {username} + ) : null}
@@ -430,7 +402,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {autoSaveError} ) : null} - {/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ) + {/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/no-access ยังไม่มีอะไรให้จัดการ) ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template @@ -481,21 +453,13 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} - {phase === "signed-out" ? ( + {phase === "no-access" ? ( + /* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
-

เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning

+

เอกสารนี้ยังใช้งาน add-in ไม่ได้

- ใช้บัญชีของ realm word-versioning — หน้าต่าง login จะเปิดแยกต่างหาก + {noAccessNotice ?? "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}

- - {loginError ? {loginError} : null}
) : null} diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 23af799..55a3826 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -4,7 +4,7 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม **ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview) -สถานะช่วงนี้: **auth และ API เป็น mock** (login ทันที + version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend/auth จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง +สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ e-Court pass-through จาก token/owner ในไฟล์ ดูหัว "ทดสอบ e-Court pass-through") ส่วน **API ยังเป็น mock** (version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง ## ไฟล์หลัก @@ -12,8 +12,8 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม |---|---| | `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` | | `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) | -| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/signed-out/untracked/tracking` + ทุก handler | -| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock | +| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler | +| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` = pass-through จริง ไม่มี login) | | `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ | | `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) | | `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) | @@ -23,7 +23,7 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม | ตัวแปร | default | ใช้เมื่อไหร่ | |---|---|---| | `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) | -| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อผู้ใช้ของ mock — แสดง header + เป็น `createdByUserId` | +| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback — ใช้ตอนไฟล์มี token แต่ไม่มี owner/uid และโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) | ## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word) @@ -52,20 +52,20 @@ Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแ ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word) -- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ) -- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + sign in mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected +- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม้นอก host (host เป็น null) → `detect()` อ่าน custom properties ไม่ได้ (ไม่มี `Word.run`) → เห็น `no-access` — ตรวจ state machine ได้แค่นี้ใน browser ปกติ ส่วน phase `untracked`/`tracking` ต้องใช้ demo ด้านล่าง (หรือ sideload ใน Word จริง) +- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected - หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง -## ทดสอบ e-Court pass-through (ข้าม login) +## ทดสอบ e-Court pass-through (auth ทางเข้าเดียว) -ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะเข้าใช้ได้เลยโดยไม่ต้องกด Sign in — `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ) +ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะใช้ได้เลย — ไม่มี login ให้กดตั้งแต่ยกเลิก Keycloak (2026-10-27): `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ) · ไฟล์ที่ไม่มีทั้งคู่ = phase `no-access` (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) -ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า login +ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า no-access · ไฟล์ที่ไม่ใส่อะไรเลย = หน้า no-access ## การสลับกลับของจริง (วันหน้า) -- **Auth:** `wordaddin/services/authService.ts` เป็น mock คง surface เดิมครบ (`login/getAccessToken/refreshAccessToken/getUsername/signOut/startAutoRefresh/...`) — เขียน implementation จริงทับ (ต้นฉบับ Keycloak PKCE + Office Dialog API อยู่ที่ `D:\word-addin-poc\addin\src\services\authService.ts`; ถ้าใช้ ต้องเพิม DialogAPI กลับเข้า manifest + หน้า auth-start/auth-callback) · `signInFromDocument` (pass-through จาก token/owner ในไฟล์) เป็น mock-only — วันหน้า token ในไฟล์ควรเป็นแค่ตัวจับคู่เอกสาร ส่วนสิทธิ์ผู้ใช้พิสูจน์ตอน runtime -- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` +- **Auth:** ไม่มี login ให้สลับแล้ว — **ยกเลิก Keycloak ถาวร 2026-10-27** (ต้นฉบับ OIDC/PKCE + Office Dialog API ของ poc เป็น historical record ที่ `D:\word-addin-poc\addin` เท่านั้น) · auth ของ add-in คือ e-Court pass-through ผ่าน `signInFromDocument` (token/owner ในไฟล์) — งานต่อยอดคือรูปแบบ/วิธี verify ของ credential ที่ e-Court stamp (ร่าง: `docs/requirement/addin-workflow.md`) · โดน 401 กลาง flow = `AuthRequiredError` → หน้า `no-access` (token จากไฟล์ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court) +- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` (contract ปัจจุบันของกลุ่ม docver สรุปไว้ใน docblock ของไฟล์นั้น + docs/PLAN-DOCVER.md — identity/docId แกะจาก Bearer token ทั้งหมด) - ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/` ## Docker diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 83ef6da..6f5f7bc 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -10,10 +10,12 @@ Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`: -ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ +ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ + +**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ e-Court pass-through: `detect()` อ่าน custom properties `token`/`owner` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInFromDocument()` ผ่านเข้าเลย ``` -checking ──(ไม่มี token)──────────────→ signed-out +checking ──(ไม่มี token/owner ในไฟล์)──→ no-access │ ├─(มี token + ไม่ใช่ tracked doc)──→ untracked └─(มี token + tracked doc)────────→ tracking @@ -21,21 +23,20 @@ checking ──(ไม่มี token)────────────── การเปลี่ยน phase อื่นๆ: -- `signed-out` → (sign in สำเร็จ) → `checking` → ตัดสินใหม่ตามไฟล์ปัจจุบัน - `untracked` → (Start Versioning สำเร็จ) → `tracking` -- ทุก phase → (401 ที่ refresh token ไม่ช่วย / กดออกจากระบบ) → `signed-out` -- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) · กลไกเดียวที่วิ่งกลับมา `checking` คือ sign-in สำเร็จ +- ทุก phase → (ไม่มี token ตอน detect ใหม่ / 401 = token หมดอายุไม่มีทางต่ออายุ) → `no-access` +- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) State ที่ UI ถือนอกเหนือจาก phase: | State | ความหมาย | |---|---| | `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 | -| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) | +| `username` | ชื่อเจ้าของไฟล์จาก pass-through (owner → uid ใน token → fallback) — แสดงเป็น chip เฉย ๆ ใน header | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `versions` + `historyLoading` + `historyError` | version history ของ `documentId` | -| `signingIn`, `loginError` | สถานะหน้า sign-in | +| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี token/owner ในไฟล์ หรือ token หมดอายุจาก 401) | | `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version | | `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน | | `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) | @@ -53,21 +54,20 @@ State ที่ UI ถือนอกเหนือจาก phase: ### Header (ทุก phase) - ชื่อแอป: **e-Court Documents Versioning** (semibold) -- ฝั่งขวา (เมื่อ login แล้วและไม่ใช่ `checking`/`signed-out`): ปุ่มเล็ก icon + `ออกจากระบบ` — username อยู่ใน `title`/`aria-label` (จอแคบ ไม่กินพื้นที่แถบหัว) — กดแล้ว sign out + กลับ `signed-out` +- ฝั่งขวา (เมื่อผ่าน pass-through แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title` ### `checking` - `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง - ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่) -### `signed-out` +### `no-access` -- ข้อความ: "เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning" -- คำอธิบายจางๆ: "ใช้บัญชีของ realm `word-versioning` — หน้าต่าง login จะเปิดแยกต่างหาก" -- ปุ่ม primary `เข้าสู่ระบบ` → ระหว่างรันเปลี่ยนเป็น `กำลังเข้าสู่ระบบ…` + disable -- ถ้า login พัง: MessageBar **warning** (`loginError`) — เช่น "เซสชันหมดอายุ — กรุณา login ใหม่" ตอนโดน 401 กลาง flow - -> ใน project จริง login เปิดหน้าต่างแยกผ่าน Office Dialog API (ห้าม redirect iframe ไป Keycloak ตรงๆ) — ถ้าโปรเจกต์ใหม่ยังไม่มี auth ให้ mock `login()` คืน `{ ok: true }` ไปก่อน +- ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้" +- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ): + - ตอน detect ไม่เจอข้อมูลในไฟล์: "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น" + - ตอนโดน 401 กลาง flow: "token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" +- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น ### `untracked` @@ -162,10 +162,10 @@ Dialog เดียวใช้ 2 flow (ปรับข้อความด้ ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout) -### Detect (FR-1) — ตอนเปิด task pane / หลัง login / กดลองใหม่ +### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่ -1. ตรวจ token — ไม่มี → `signed-out` -2. อ่าน custom properties ครั้งเดียวทั้งชุด: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` +1. อ่าน custom properties ครั้งเดียวทั้งชุด: `token`, `owner`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` +2. e-Court pass-through: ไม่มี `token`/`owner` เลย (หรืออ่านไฟล์ไม่ได้) → `no-access` · มีอย่างน้อยหนึ่งตัว → `signInFromDocument` (username จาก owner → uid ใน token) แล้วไปต่อ 3. `tracked = (SavedByAddIn === GUID ของ add-in)`: - tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history - ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้) @@ -200,7 +200,7 @@ dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดั ### Auto-save (FR-17) -- เริ่มตั้งแต่ mount **ไม่รอ sign-in** +- เริ่มตั้งแต่ mount **ไม่ผูกกับ phase** (detect ยังไม่จบก็รันได้) - periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม - save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar - ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture) @@ -251,17 +251,17 @@ interface VersionRecord { response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น -### Auth — OIDC PKCE ผ่าน Office Dialog API +### Auth — e-Court pass-through (ไม่มี login) -`displayDialogAsync` เปิดหน้า login แยก → `messageParent` ส่งผลกลับ task pane · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) · refresh token auto-refresh เงียบๆ จน 401 → สถานะ `AuthRequiredError` · mock ช่วงรอ: `login()` คืน `{ ok: true }`, `getAccessToken()` คืน token ปลอม, `getUsername()` คืนชื่อ test +ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties `token`/`owner` ลงไฟล์ตอนสร้าง แล้ว `signInFromDocument()` ใช้ได้ทันที · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) และ**ต่ออายุไม่ได้** — โดน 401 = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md) · **รูปแบบ/วิธี verify ของ credential ที่ e-Court stamp ลงไฟล์ยังเป็นงานออกแบบต่อ** (ร่าง requirement: `docs/requirement/addin-workflow.md`) -> **หมายเหตุสถานะปัจจุบัน (2026-10-14):** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — ทั้ง auth (แทนด้วย mock `wordaddin/services/authService.ts`) และ API ทั้งสามข้างบน (แทนด้วย in-memory mock `wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md` +> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น e-Court pass-through จริง (`wordaddin/services/authService.ts` — ไม่มี mock ให้ login แล้ว) ส่วน API ทั้งสามข้างบนยังเป็น in-memory mock (`wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md` --- ## 8. กติกา error ที่ UI ต้องรองรับ -1. **`AuthRequiredError` (401 ที่ refresh ไม่ช่วย)** — ทุก flow ต้อง catch แล้ว: กลับ `signed-out` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `loginError` = "เซสชันหมดอายุ — กรุณา login ใหม่" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** +1. **`AuthRequiredError` (ไม่มี token / 401)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** 2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection 3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว 4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow diff --git a/docs/requirement/addin-workflow.md b/docs/requirement/addin-workflow.md new file mode 100644 index 0000000..a4a6bf1 --- /dev/null +++ b/docs/requirement/addin-workflow.md @@ -0,0 +1,44 @@ +# ขั้นตอนการทำงานของ Addin + +## 1. เมื่อเปิดเอกสาร + +- อ่าน custom property ของ word + 1. ticket_data ซึ่งจะมีข้อมูลเบื้องต้น ดังนี้ + ```jsonc + { + "exp": 1793067433, + "fid": "T12_FD155_3s-long_ZZZ", + "tid": "T12", + "uid": "oom", + "uname": "สรวิชญ์", + } + ``` + 2. นำค่า ticket_data ที่เป็น string ก่อนนำไปแปลง json ทำการ sign HMAC-SHA256 โดยใช้ secret "super_secret" ซึ่งต้องรองรับการ config ค่านี้ในอนาคต + 3. นำค่าที่ sign ได้ มาเทียบกับค่า property "HmacSha256" +- ขั้นตอนการทำ authen + ``` + ขั้นตอน + 1. เมื่อเปิดไฟล์ Word ให้อ่าน Custom Property 2 ค่าที่ฝังมาในไฟล์ คือ value จริง และ Hash Value (HMAC) เพื่อตรวจสอบว่าไฟล์สร้างผ่านระบบอย่างถูกต้อง + 2. ถ้า HMAC ไม่ตรงกัน แสดงว่าไฟล์ถูกแก้ไข ให้ Add-in disable การทำงานทั้งหมด + 3. ถ้า HMAC ตรงกัน ให้เก็บค่า uId, uName, fId, tid ไว้ แล้วตรวจสอบว่า userId นี้มีใน localStorage อยู่แล้วหรือไม่ + 4. ถ้ายังไม่มีให้ตรวจค่า Exp ที่ได้จากไฟล์ Word + - หมดอายุ → disable Add-in ทั้งหมด + - ยังไม่หมดอายุ → เก็บใน localStorage + - userName: ค่าจาก uName + - คีย์คือ fid: เอาค่า { + "userid": uid, + "username": uname, + "fid": fid (เป็น  string), + "tid": tid + } + ไปทำการขอ token ที่ /api/documents/token แล้วนำค่าที่ได้มาเก็บไว้ที่ตัวแปรนี้ เพื่อให้อ่านค่าไปใช้ให้ตรงกับค่า fid ที่ได้รับ เนื่องจาก api ในส่วน /documents จะต้องแนบ token ไปไม่ส่งค่าไปตรงๆ เพื่อความถูกต้อง + 5. ถ้ามี Session อยู่แล้ว ให้ตรวจว่า userId ตรงกับ Session หรือไม่ + 6. ถ้า userId ไม่ตรงกัน (user ใหม่) ให้ตรวจค่า Expire + - ยังไม่หมดอายุ → เคลียร์ Session เดิม แล้วสร้าง Session 2 ค่าใหม่ + - หมดอายุ → disable Add-in ทั้งหมด + 7. ถ้า userId ตรงกัน (คนเดิม) ให้ตรวจว่า fid มีใน localStorage แล้วหรือไม่ + - มีแล้ว (กลับมาทำเอกสารเดิม) → ผ่านได้เลย ไม่ต้องตรวจ Expire + - ยังไม่มี (เอกสารใหม่) → ตรวจค่า Expire + - ยังไม่หมดอายุ → ทำการขอ token แล้วเก็บใน localStorage ตามขั้นตอนก่อนหน้า + - หมดอายุ → disable Add-in ทั้งหมด + ``` diff --git a/public/wordaddin/manifest.template.xml b/public/wordaddin/manifest.template.xml index efe2e93..a51ebb1 100644 --- a/public/wordaddin/manifest.template.xml +++ b/public/wordaddin/manifest.template.xml @@ -11,8 +11,10 @@ ผลต่างจากต้นฉบับ: - taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack - - ตัด DialogAPI (login ช่วงนี้เป็น mock ไม่มี Keycloak dialog) — เหลือ WordApi 1.5 + OpenBrowserWindowApi - - ตัด AppDomain ของ Keycloak (https://localhost:8443) + - ตัด DialogAPI + AppDomain ของ Keycloak (https://localhost:8443) — ยกเลิก auth ด้วย Keycloak + 2026-10-27: ไม่มี login ใน add-in auth เป็น e-Court pass-through (token/owner ใน custom + properties ของไฟล์) requirement set เหลือ WordApi 1.3 + OpenBrowserWindowApi + (ดูรายละเอียดระดับที่ใช้จริงในคอมเมนต์ก่อน ) - Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane) - คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม --> diff --git a/wordaddin/services/apiClient.ts b/wordaddin/services/apiClient.ts index 59f9e06..73cf510 100644 --- a/wordaddin/services/apiClient.ts +++ b/wordaddin/services/apiClient.ts @@ -2,17 +2,17 @@ import { config } from "../config"; import * as authService from "./authService"; /** - * fetch wrapper ของ add-in — แนบ Bearer ทุก request - * โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError - * (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9) - * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts + * fetch wrapper ของ add-in — แนบ Bearer ทุก request (token จาก custom properties ของไฟล์) + * โดน 401 → AuthRequiredError ทันที: token จากไฟล์ต่ออายุไม่ได้ (ไม่มี refresh token + * ตั้งแต่ยกเลิก Keycloak) — UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court + * พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts * (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError * ถูก TaskpaneApp ใช้อยู่แล้ว) */ -/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */ +/** บอกว่า token จากไฟล์ใช้ไม่ได้ (ไม่มี / หมดอายุ / ไม่ถูกต้อง) — ต้องได้ไฟล์ใหม่จาก e-Court */ export class AuthRequiredError extends Error { - constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") { + constructor(message = "token ของ e-Court หมดอายุหรือไม่ถูกต้อง") { super(message); this.name = "AuthRequiredError"; } @@ -54,25 +54,17 @@ async function doFetch( } /** - * request พร้อม Bearer + retry-on-401 ครั้งเดียว - * Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError) + * request พร้อม Bearer — 401 = token จากไฟล์หมดอายุ/ไม่ถูกต้อง → AuthRequiredError + * Response ที่ได้คืนไปให้ caller เช็ค ok เอง */ export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise { const accessToken = await authService.getAccessToken(); if (!accessToken) { throw new AuthRequiredError(); } - - let response = await doFetch(path, options, accessToken); + const response = await doFetch(path, options, accessToken); if (response.status === 401) { - const refreshedToken = await authService.refreshAccessToken(); - if (!refreshedToken) { - throw new AuthRequiredError(); - } - response = await doFetch(path, options, refreshedToken); - if (response.status === 401) { - throw new AuthRequiredError(); - } + throw new AuthRequiredError(); } return response; } diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts index 7cd7509..b625ff3 100644 --- a/wordaddin/services/authService.ts +++ b/wordaddin/services/authService.ts @@ -1,34 +1,21 @@ /** - * MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต) - * ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API, - * token เก็บใน memory เท่านั้น, auto-refresh) + * Auth ของ add-in — e-Court pass-through ทางเดียว (ยกเลิก Keycloak 2026-10-27 ตามคำขอผู้ใช้) + * ไม่มีขั้นตอน login ใน add-in อีกต่อไป: ไฟล์ที่ e-Court stamp custom properties + * `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = ใช้งานได้ทันที ไม่ต้องกดปุ่มใด ๆ + * ไฟล์ที่ไม่มีทั้งคู่ = phase no-access ของ TaskpaneApp (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) * - * วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว - * (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ + * token เก็บใน memory เท่านั้น — getAccessToken คืน docToken ให้ apiClient แนบ Bearer + * (ช่วงไม่ต่อ backend จริง versioningService เป็น mock ไม่เรียก apiClient อยู่แล้ว) */ import { config } from "../config"; -/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */ -export type LoginResult = { ok: true } | { ok: false; error: string }; - -/** state เดียวของ mock — null = signed out */ +/** state เดียว — null = ยังไม่เคย pass-through สำเร็จ (จากไฟล์ หรือ ?addindemo) */ let username: string | null = null; /** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */ let docToken: string | null = null; -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */ -export async function login(): Promise { - await delay(300); - username = config.mockUsername; - return { ok: true }; -} - -/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */ +/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */ export function signInForDemo(): void { username = config.mockUsername; } @@ -50,8 +37,8 @@ function decodeJwtField(token: string, field: string): string | undefined { } /** - * e-Court pass-through (ระยะแรก) — ไฟล์ที่ e-Court stamp `token`/`owner` ไว้ใน custom properties - * ใช้งานได้เลยโดยไม่ต้องกด login: username จาก owner → uid ใน token → mockUsername + * e-Court pass-through — จุดเข้าเดียวของ auth: ไฟล์ที่ stamp `token`/`owner` ไว้ + * username ได้จาก owner → uid ใน JWT → mockUsername (ตามลำดับ) * คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว) */ export function signInFromDocument(input: { token?: string; owner?: string }): boolean { @@ -65,33 +52,12 @@ export function signInFromDocument(input: { token?: string; owner?: string }): b return true; } +/** Bearer ของ apiClient — docToken จากไฟล์ (ส่วน demo/mock คืน token ปลอม) */ export async function getAccessToken(): Promise { if (docToken) return docToken; return username ? `mock-token-${username}` : null; } -/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */ -export async function refreshAccessToken(): Promise { - return getAccessToken(); -} - export function getUsername(): string | undefined { return username ?? undefined; } - -export function isTokenValid(): boolean { - return username !== null; -} - -export function hasRefreshToken(): boolean { - return username !== null; -} - -export function startAutoRefresh(): void { - // mock: ไม่มีอะไรต้อง refresh -} - -export function signOut(): void { - username = null; - docToken = null; -} diff --git a/wordaddin/services/metadataService.ts b/wordaddin/services/metadataService.ts index 1119a3b..74c33d3 100644 --- a/wordaddin/services/metadataService.ts +++ b/wordaddin/services/metadataService.ts @@ -23,7 +23,7 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp"; /** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */ export const TEMPLATE_ID_PROPERTY = "TemplateId"; -/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลยไม่ต้องกด login */ +/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลย (auth ทางเข้าเดียว) */ export const ECOURT_TOKEN_PROPERTY = "token"; export const ECOURT_OWNER_PROPERTY = "owner"; @@ -35,7 +35,7 @@ export interface TrackedDocumentProperties { lastVersionTimestamp?: string; /** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */ templateId?: string; - /** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ข้ามหน้า login (username จาก owner หรือ uid ใน token) */ + /** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ใช้งานได้ทันที (username จาก owner หรือ uid ใน token) */ ecourtToken?: string; ecourtOwner?: string; }