feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
8ebe5f8644
commit
cffc571da5
21 changed files with 1725 additions and 124 deletions
|
|
@ -24,12 +24,13 @@ import VersionHistoryPanel from "./VersionHistoryPanel";
|
|||
|
||||
/**
|
||||
* 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` ผ่านเลย
|
||||
* checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
|
||||
* → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
|
||||
* → (ticket ผ่าน + tracked document) tracking
|
||||
* ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
|
||||
* ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties
|
||||
* แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token
|
||||
* ตาม fid — PLAN-ADDIN-AUTH.md)
|
||||
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path,
|
||||
* JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login,
|
||||
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
|
||||
|
|
@ -138,8 +139,9 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
|
||||
const [hasUncommitted, setHasUncommitted] = useState(false);
|
||||
|
||||
/** AuthRequiredError (ไม่มี token / 401) → หน้า no-access — token จากไฟล์ต่ออายุไม่ได้
|
||||
* ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
|
||||
/** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
|
||||
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
|
||||
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
|
||||
const handleAuthRequired = useCallback((error: unknown): boolean => {
|
||||
if (error instanceof AuthRequiredError) {
|
||||
setPhase("no-access");
|
||||
|
|
@ -147,7 +149,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
setDocumentId(null);
|
||||
setDocumentTemplateId(null);
|
||||
setVersions([]);
|
||||
setNoAccessNotice("token ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
|
||||
setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -170,11 +172,12 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
[handleAuthRequired],
|
||||
);
|
||||
|
||||
/** ตรวจตอนเปิด task pane (FR-1): token/owner ในไฟล์ → SavedByAddIn → history
|
||||
/** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
|
||||
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
|
||||
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
|
||||
* e-Court pass-through — auth ทางเข้าเดียว: ไฟล์ที่ stamp token/owner มา = ผ่านเข้าเลย
|
||||
* ไม่มีขั้นตอน login (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
|
||||
* auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
|
||||
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
|
||||
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
|
||||
const detect = useCallback(async () => {
|
||||
setPhase("checking");
|
||||
setBootError(null);
|
||||
|
|
@ -185,19 +188,22 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
} catch {
|
||||
properties = null;
|
||||
}
|
||||
if (
|
||||
!properties ||
|
||||
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner })
|
||||
) {
|
||||
setNoAccessNotice(
|
||||
"ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น",
|
||||
);
|
||||
if (!properties) {
|
||||
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
|
||||
setPhase("no-access");
|
||||
return;
|
||||
}
|
||||
const signIn = await authService.signInWithTicket({
|
||||
ticketData: properties.ticketData,
|
||||
ticketHmac: properties.ticketHmac,
|
||||
});
|
||||
if (!signIn.ok) {
|
||||
setNoAccessNotice(signIn.message);
|
||||
setPhase("no-access");
|
||||
return;
|
||||
}
|
||||
setUsername(authService.getUsername() ?? null);
|
||||
|
||||
properties ??= await metadataService.readTrackedProperties();
|
||||
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
|
||||
const id = tracked ? properties.documentTrackingId : undefined;
|
||||
if (!id) {
|
||||
|
|
@ -458,7 +464,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
|
|||
<div className="flex flex-col gap-2 pt-4">
|
||||
<p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
|
||||
<p className="text-sm text-ink-soft">
|
||||
{noAccessNotice ?? "ไม่พบ token/owner ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
||||
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue