implement phase 5 และ fix bugs
This commit is contained in:
parent
f4b1c3e14b
commit
1bb850c6b8
40 changed files with 1507 additions and 429 deletions
|
|
@ -15,7 +15,7 @@ MINIO_ROOT_USER=wordversioning-minio
|
||||||
MINIO_ROOT_PASSWORD=dev-local-pass
|
MINIO_ROOT_PASSWORD=dev-local-pass
|
||||||
|
|
||||||
# --- Keycloak bootstrap admin (service: keycloak) ---
|
# --- Keycloak bootstrap admin (service: keycloak) ---
|
||||||
# ใช้ล็อกอิน admin console ครั้งแรกที่ http://localhost:8080/admin/
|
# ใช้ล็อกอิน admin console ครั้งแรกที่ https://localhost:8443/admin/ (cert dev ต้อง install ก่อน — ดู README)
|
||||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass
|
KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass
|
||||||
|
|
||||||
|
|
|
||||||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## โปรเจกต์นี้คืออะไร
|
## โปรเจกต์นี้คืออะไร
|
||||||
|
|
||||||
ระบบ **Word Versioning**: จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน:
|
ระบบ **Word Versioning** (ชื่อที่แสดงใน UI: **e-Court Documents Versioning**): จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน:
|
||||||
|
|
||||||
| ส่วน | Stack | หน้าที่ |
|
| ส่วน | Stack | หน้าที่ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||||
|
|
||||||
**สถานะปัจจุบัน: Phase 0–3 เสร็จ** (scaffold + infra + backend core FR-5–8 + backend template & generation FR-9–11) — งานต่อคือ implement phase 4–8 ในแผน (4–5 add-in กับ 6 web ทำขนานกันได้)
|
**สถานะปัจจุบัน: Phase 0–5 เสร็จ** (backend ครบ + add-in ครบทั้ง services และ task pane UI — sideload ทดสอบจริงบน Word for Mac แล้ว แก้ปัญหาตกมุม 4 รอบ) — งานต่อคือ phase 6 (web portal), 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน
|
||||||
|
|
||||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||||
|
|
||||||
|
|
@ -35,6 +35,9 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
|
||||||
8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่
|
8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่
|
||||||
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
|
||||||
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
|
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
|
||||||
|
11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up`
|
||||||
|
12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง)
|
||||||
|
13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
|
||||||
|
|
||||||
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
|
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
|
||||||
|
|
||||||
|
|
@ -44,16 +47,17 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
|
||||||
|
|
||||||
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
||||||
|
|
||||||
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend proxy stream เท่านั้น
|
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง)
|
||||||
|
|
||||||
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
|
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
|
||||||
|
|
||||||
## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0)
|
## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d # infra: PostgreSQL + MinIO + Keycloak
|
docker compose up -d # infra: PostgreSQL + MinIO + Keycloak(https:8443)
|
||||||
|
python3 scripts/dev-token.py # token PKCE จริงสำหรับทดสอบ backend/Swagger (admin-cli ไม่มี aud ใช้ไม่ได้)
|
||||||
|
|
||||||
# backend
|
# backend (http://localhost:5075 + https://localhost:7232 — dev เปิด https อัตโนมัติด้วย dev cert)
|
||||||
cd backend/WordVersioning.Api
|
cd backend/WordVersioning.Api
|
||||||
dotnet run # API + swagger
|
dotnet run # API + swagger
|
||||||
dotnet ef migrations add <Name>
|
dotnet ef migrations add <Name>
|
||||||
|
|
@ -62,11 +66,11 @@ dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เ
|
||||||
|
|
||||||
# add-in (https://localhost:3000)
|
# add-in (https://localhost:3000)
|
||||||
cd addin
|
cd addin
|
||||||
|
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว — ทำก่อน docker compose up
|
||||||
npm run build # webpack build
|
npm run build # webpack build
|
||||||
npm start # dev server — sideload ผ่าน manifest
|
npm start # dev server — sideload ผ่าน manifest (symlink)
|
||||||
npm test
|
npm test
|
||||||
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
|
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
|
||||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว
|
|
||||||
|
|
||||||
# web portal (http://localhost:5173 — ห้ามใช้ 3000)
|
# web portal (http://localhost:5173 — ห้ามใช้ 3000)
|
||||||
cd web
|
cd web
|
||||||
|
|
|
||||||
72
README.md
72
README.md
|
|
@ -1,4 +1,4 @@
|
||||||
# Word Versioning Add-in
|
# e-Court Documents Versioning (Word Add-in)
|
||||||
|
|
||||||
ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ
|
ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ
|
||||||
|
|
||||||
|
|
@ -35,11 +35,11 @@
|
||||||
| 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ |
|
| 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ |
|
||||||
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
|
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
|
||||||
| 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ |
|
| 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ |
|
||||||
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ⏳ รอ implement |
|
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด |
|
||||||
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
|
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
|
||||||
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
|
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
|
||||||
|
|
||||||
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in มี services layer พร้อมใช้ครบพร้อม unit tests 40 ตัว (รอ Phase 5 เอาไปต่อ UI)
|
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in ครบทั้ง services layer และ task pane UI พร้อม unit tests 45 ตัว
|
||||||
|
|
||||||
## เอกสาร
|
## เอกสาร
|
||||||
|
|
||||||
|
|
@ -58,6 +58,11 @@
|
||||||
dotnet tool install --global dotnet-ef --version 8.0.11
|
dotnet tool install --global dotnet-ef --version 8.0.11
|
||||||
```
|
```
|
||||||
- Node.js LTS (add-in และ web)
|
- Node.js LTS (add-in และ web)
|
||||||
|
- **Dev cert สำหรับ localhost** (ต้องทำ**ก่อน** `docker compose up` — Keycloak mount cert จากที่นี่):
|
||||||
|
```bash
|
||||||
|
cd addin && npx office-addin-dev-certs install
|
||||||
|
```
|
||||||
|
ทำครั้งเดียวต่อเครื่อง: สร้าง CA + cert ที่ trust ไว้ใน keychain ใช้ทั้ง add-in dev server (https://localhost:3000) และ Keycloak (https://localhost:8443)
|
||||||
|
|
||||||
### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
|
### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
|
||||||
|
|
||||||
|
|
@ -70,10 +75,12 @@ docker compose up -d
|
||||||
|
|
||||||
| บริการ | URL | เข้าด้วย |
|
| บริการ | URL | เข้าด้วย |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Keycloak admin console | http://localhost:8080/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` |
|
| Keycloak admin console | https://localhost:8443/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` |
|
||||||
| MinIO console | http://localhost:9001 | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` จาก `.env` |
|
| MinIO console | http://localhost:9001 | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` จาก `.env` |
|
||||||
| PostgreSQL | `localhost:5432` | `POSTGRES_USER` / `POSTGRES_PASSWORD` จาก `.env`, ฐานข้อมูล `wordversioning` |
|
| PostgreSQL | `localhost:5432` | `POSTGRES_USER` / `POSTGRES_PASSWORD` จาก `.env`, ฐานข้อมูล `wordversioning` |
|
||||||
|
|
||||||
|
> **ทำไม Keycloak ต้องเป็น HTTPS:** Office Dialog API ที่ add-in ใช้เปิดหน้า login **ปฏิเสธ http:// โดยสมบูรณ์** (error: "The HTTP protocol is not supported. Use HTTPS instead") — dev จึงให้ Keycloak เสิร์ฟ HTTPS ที่ port 8443 ด้วย cert ของ `office-addin-dev-certs` (CA ถูก trust ในเครื่องแล้ว Word/webview จึงเชื่อ) · ตัว issuer ใน token จะเป็น `https://localhost:8443/realms/word-versioning` ซึ่ง backend ตั้ง `Authentication:Authority` ตรงกัน
|
||||||
|
|
||||||
Keycloak จะ import realm `word-versioning` อัตโนมัติ (clients 3 ตัว + test user) จาก [keycloak/realm-export.json](keycloak/realm-export.json) — ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev: แก้ realm ให้แก้ที่ไฟล์นี้แล้วรัน `docker compose rm -sf keycloak && docker compose up -d` เพื่อ import ใหม่ (การแก้ผ่าน admin console จะหายเมื่อ recreate container)
|
Keycloak จะ import realm `word-versioning` อัตโนมัติ (clients 3 ตัว + test user) จาก [keycloak/realm-export.json](keycloak/realm-export.json) — ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev: แก้ realm ให้แก้ที่ไฟล์นี้แล้วรัน `docker compose rm -sf keycloak && docker compose up -d` เพื่อ import ใหม่ (การแก้ผ่าน admin console จะหายเมื่อ recreate container)
|
||||||
|
|
||||||
**Test user สำหรับล็อกอิน:** `testuser` / `Test1234!` (เปลี่ยนได้ใน realm-export.json)
|
**Test user สำหรับล็อกอิน:** `testuser` / `Test1234!` (เปลี่ยนได้ใน realm-export.json)
|
||||||
|
|
@ -85,9 +92,11 @@ MinIO จะมี bucket `docx-versions` และ `docx-templates` (private
|
||||||
```bash
|
```bash
|
||||||
cd backend/WordVersioning.Api
|
cd backend/WordVersioning.Api
|
||||||
dotnet ef database update # ครั้งแรก / เมื่อมี migration ใหม่
|
dotnet ef database update # ครั้งแรก / เมื่อมี migration ใหม่
|
||||||
dotnet run # Swagger ที่ http://localhost:5075/swagger
|
dotnet run # รันทั้ง http://localhost:5075 และ https://localhost:7232 (Swagger ใช้ https ก็ได้)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **ทำไม dev ต้องมี endpoint HTTPS ด้วย:** task pane ของ add-in เป็นหน้า `https://localhost:3000` และ **webview ของ Word (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ** — error ที่เห็นคือ "Load failed" — ส่วน CORS ก็ไม่ช่วยเพราะ request ไม่เคยถึง server · dev จึงเปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกับ Keycloak (`~/.office-addin-dev-certs`, ปิดได้ผ่าน `DevHttps:Enabled=false` ใน `appsettings.Development.json`) · add-in ชี้ API ที่ https ตัวนี้อัตโนมัติ
|
||||||
|
|
||||||
ค่าเชื่อมต่อ (DB/MinIO/Keycloak) อยู่ใน `appsettings.json` + `appsettings.Development.json` — override ผ่าน env ได้ทั้งหมด เช่น `ConnectionStrings__Default`, `MinIO__Endpoint`, `Authentication__Authority`
|
ค่าเชื่อมต่อ (DB/MinIO/Keycloak) อยู่ใน `appsettings.json` + `appsettings.Development.json` — override ผ่าน env ได้ทั้งหมด เช่น `ConnectionStrings__Default`, `MinIO__Endpoint`, `Authentication__Authority`
|
||||||
|
|
||||||
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
|
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
|
||||||
|
|
@ -96,7 +105,8 @@ dotnet run # Swagger ที่ http://localhost:5075/swagger
|
||||||
# Documents / versions (FR-5–7, 11)
|
# Documents / versions (FR-5–7, 11)
|
||||||
POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync
|
POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync
|
||||||
GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields)
|
GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields)
|
||||||
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
|
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
|
||||||
|
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO)
|
||||||
|
|
||||||
# Generate จาก template (FR-10)
|
# Generate จาก template (FR-10)
|
||||||
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
|
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
|
||||||
|
|
@ -117,12 +127,12 @@ DELETE /api/templates/{id} # deactivate (s
|
||||||
|
|
||||||
### ขอ token ทดสอบ (dev)
|
### ขอ token ทดสอบ (dev)
|
||||||
|
|
||||||
ทุก client บังคับ PKCE (ปิด direct grants ตาม spec) — วิธีเร็วสุดตอนทดสอบคือขอผ่าน client `admin-cli` ของ realm:
|
ทุก client ใน realm บังคับ PKCE (ปิด direct grants ตาม spec) และ token ของ `admin-cli` **ไม่มี claim `aud`** จึงเรียก backend ไม่ผ่าน (401) — ใช้สคริปต์นี้เดิน PKCE flow จริงแทน (login ด้วย test user ให้อัตโนมัติ):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://localhost:8080/realms/word-versioning/protocol/openid-connect/token \
|
python3 scripts/dev-token.py # ปริ้น access token
|
||||||
-d "client_id=admin-cli&grant_type=password&username=testuser&password=Test1234!" \
|
python3 scripts/dev-token.py --clip # หรือคัดลอกเข้า clipboard
|
||||||
| jq -r .access_token
|
TOKEN=$(python3 scripts/dev-token.py) # เก็บใส่ตัวแปร
|
||||||
```
|
```
|
||||||
|
|
||||||
แล้วลอง upload:
|
แล้วลอง upload:
|
||||||
|
|
@ -186,13 +196,13 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
|
||||||
|
|
||||||
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
|
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
|
||||||
|
|
||||||
### 3) Add-in (services layer พร้อม — UI ตามมาใน Phase 5)
|
### 3) Add-in (ครบทั้ง services layer และ task pane UI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd addin
|
cd addin
|
||||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
|
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
|
||||||
npm run build # webpack build + type-check ทั้ง program
|
npm run build # webpack build + type-check ทั้ง program
|
||||||
npm test # Jest — unit tests ของ services layer (40 ตัว)
|
npm test # Jest — unit tests (45 ตัว)
|
||||||
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
|
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
|
||||||
npm run validate # ตรวจ manifest.xml
|
npm run validate # ตรวจ manifest.xml
|
||||||
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
|
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
|
||||||
|
|
@ -206,21 +216,35 @@ npm start # dev server https://localhost:3000 — sid
|
||||||
| `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) |
|
| `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) |
|
||||||
| `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ |
|
| `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ |
|
||||||
| `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in |
|
| `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in |
|
||||||
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion |
|
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion + getDownloadLink (presigned) |
|
||||||
|
| `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec |
|
||||||
|
|
||||||
หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json)
|
หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json)
|
||||||
|
|
||||||
**Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่):
|
**Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
KEYCLOAK_AUTHORITY=http://localhost:8080/realms/word-versioning \
|
KEYCLOAK_AUTHORITY=https://localhost:8443/realms/word-versioning \
|
||||||
API_BASE_URL=http://localhost:5075 \
|
API_BASE_URL=https://localhost:7232 \
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
(ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`)
|
(ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`)
|
||||||
|
|
||||||
**ข้อจำกัดชั่วคราว:** task pane ยังเป็นหน้า demo ของ scaffold — Phase 5 จะเอา services ชุดนี้ไป wire เป็น UI จริง (sign-in → detect → Save & Version → history)
|
**Task pane UI (Phase 5)** — state machine `checking → signed-out → untracked → tracking`:
|
||||||
|
|
||||||
|
- **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v1 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **Start Versioning This Document**
|
||||||
|
- **Save & Version (FR-2)** — confirm dialog → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection
|
||||||
|
- **Soft reminder (FR-3)** — ระหว่าง tracking ถามทุก 30 วิ ว่ามีแก้ไขที่ยังไม่ได้ version → banner เตือน (ไม่ block)
|
||||||
|
- **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์)
|
||||||
|
- **Session หมดอายุ (AC-9)** — 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in พร้อมข้อความ
|
||||||
|
|
||||||
|
**Sideload เพื่อทดสอบใน Word จริง** (ต้องรัน infra + backend + `npm start` ก่อน):
|
||||||
|
|
||||||
|
- **Word Desktop (Mac/Windows)**: `cd addin && npm start` (ใช้ office-addin-debugging sideload ให้อัตโนมัติ) หรือ insert manual: Word → Insert → My Add-ins → Upload My Add-in → เลือก `addin/manifest.xml`
|
||||||
|
- **Word Online**: ไฟล์ใดก็ได้ใน OneDrive/SharePoint → Insert → Office Add-ins → Upload My Add-in → เลือก `addin/manifest.xml`
|
||||||
|
- ไล่ทดสอบตาม acceptance criteria: login ผ่าน dialog → เปิดไฟล์ใหม่เห็นปุ่ม Start Versioning → กดแล้วได้ v1 → แก้ไขแล้ว Save & Version → history เพิ่ม + ดาวน์โหลด version เก่าเปิดได้
|
||||||
|
- ⚠️ **ห้าม `cp` ทับ manifest ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`** — `npm start` สร้าง symlink ไว้ การทับทำให้ครั้งถัดไปขึ้น `EEXIST` — ถาวรแก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word (ถ้าเจอ EEXIST แล้ว: ลบไฟล์ใน wef ทิ้งแล้ว `npm start` ใหม่)
|
||||||
|
|
||||||
### 4) Web Portal (ยังเป็น scaffold)
|
### 4) Web Portal (ยังเป็น scaffold)
|
||||||
|
|
||||||
|
|
@ -232,6 +256,22 @@ logic จริง (login, template UI, dynamic form) มาตาม Phase 6
|
||||||
|
|
||||||
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
|
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
|
||||||
|
|
||||||
|
## Troubleshooting (จากการทดสอบจริงบน Word for Mac)
|
||||||
|
|
||||||
|
ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน:
|
||||||
|
|
||||||
|
| อาการ | สาเหตุ | วิธีแก้ |
|
||||||
|
|---|---|---|
|
||||||
|
| กด Sign in แล้ว "The HTTP protocol is not supported. Use HTTPS instead" | Office Dialog API **ปฏิเสธ http:// โดยสมบูรณ์** แต่ Keycloak dev เดิมรัน http://8080 | Keycloak เสิร์ฟ HTTPS ที่ `https://localhost:8443` ด้วย cert ของ office-addin-dev-certs (docker-compose mount `~/.office-addin-dev-certs` เข้า container) |
|
||||||
|
| Login ผ่านแต่ทุก API call ขึ้น "Load failed" | **WKWebView บล็อก fetch จากหน้า https ไป http (mixed content)** — task pane เป็น `https://localhost:3000` แต่ API เป็น `http://localhost:5075` (CORS ไม่เกี่ยว — ตรวจแล้วเรียบร้อย) | backend เปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกัน (`DevHttps:*` ใน `appsettings.Development.json`) — add-in ชี้อันนี้อัตโนมัติ |
|
||||||
|
| กด Start Versioning แล้ว "รูปแบบข้อมูลของ slice ไม่รองรับ" | host แต่ละตัวส่ง `slice.data` คนละรูป (Win = ArrayBuffer/view, **Mac = base64 string หรือ number[]**) | `fileService.sliceDataToBytes()` normalize ครบทั้ง 4 รูปแบบ |
|
||||||
|
| กดดาวน์โหลด version แล้ว**ไม่มีไฟล์โผล่ที่ไหนเลย** | **webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ** | ขอ presigned URL อายุ 2 นาทีจาก `download-link` แล้วเปิดใน**เบราว์เซอร์ของเครื่อง** (`openBrowserWindow`) — ไฟล์ลงโฟลเดอร์ Downloads ของเบราว์เซอร์ |
|
||||||
|
| แก้ webpack.config.js แล้วค่าเก่ายังติดอยู่ | webpack **ไม่ restart เองเมื่อ config เปลี่ยน** + dev-server ตัวเก่าถือ port 3000 อยู่ (ตัวใหม่ขึ้นไม่ได้) | kill dev-server เดิม (`lsof -ti :3000 | xargs kill`) แล้ว `npm start` ใหม่ทุกครั้งที่แก้ config |
|
||||||
|
| `npm start` ขึ้น `EEXIST ... wef/...manifest.xml` | มีการ `cp` ทับ symlink ของ sideload จนกลายเป็นไฟล์ธรรมดา | ลบไฟล์นั้นใน wef folder ทิ้งแล้ว `npm start` ใหม่ (ดูคำเตือนในหัวข้อ Sideload) |
|
||||||
|
| curl token จาก `admin-cli` แล้วเรียก backend ได้ 401 | token ของ admin-cli **ไม่มี claim `aud`** (ไม่ได้อยู่ใน realm-export จึงไม่มี audience mapper) | ใช้ `python3 scripts/dev-token.py` (เดิน PKCE จริงด้วย client `word-versioning-web`) |
|
||||||
|
|
||||||
|
**กติกาที่สรุปได้:** ใน dev ทุก service ที่ task pane (หน้า https) จะเรียก **ต้องเป็น HTTPS ด้วย cert จาก office-addin-dev-certs ทั้งหมด** — Keycloak (8443) และ backend (7232)
|
||||||
|
|
||||||
## เทคโนโลยี
|
## เทคโนโลยี
|
||||||
|
|
||||||
- **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office`
|
- **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office`
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json",
|
|
||||||
"id": "f2b103f1-1ab1-4e1b-8f0b-072aa3d4e19d",
|
|
||||||
"manifestVersion": "1.17",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"name": {
|
|
||||||
"short": "Contoso Task Pane Add-in",
|
|
||||||
"full": "Contoso Task Pane Add-in"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"short": "A template to get started.",
|
|
||||||
"full": "This is the template to get started."
|
|
||||||
},
|
|
||||||
"developer": {
|
|
||||||
"name": "Contoso",
|
|
||||||
"websiteUrl": "https://www.contoso.com",
|
|
||||||
"privacyUrl": "https://www.contoso.com/privacy",
|
|
||||||
"termsOfUseUrl": "https://www.contoso.com/servicesagreement"
|
|
||||||
},
|
|
||||||
"icons": {
|
|
||||||
"outline": "assets/outline.png",
|
|
||||||
"color": "assets/color.png"
|
|
||||||
},
|
|
||||||
"accentColor": "#230201",
|
|
||||||
"localizationInfo": {
|
|
||||||
"defaultLanguageTag": "en-us",
|
|
||||||
"additionalLanguages": []
|
|
||||||
},
|
|
||||||
"authorization": {
|
|
||||||
"permissions": {
|
|
||||||
"resourceSpecific": [
|
|
||||||
{
|
|
||||||
"name": "Mailbox.ReadWrite.User",
|
|
||||||
"type": "Delegated"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"validDomains": ["contoso.com"],
|
|
||||||
"extensions": [
|
|
||||||
{
|
|
||||||
"requirements": {
|
|
||||||
"scopes": ["mail"],
|
|
||||||
"capabilities": [
|
|
||||||
{ "name": "Mailbox", "minVersion": "1.3" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"runtimes": [
|
|
||||||
{
|
|
||||||
"requirements": {
|
|
||||||
"capabilities": [
|
|
||||||
{ "name": "Mailbox", "minVersion": "1.3" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"id": "TaskPaneRuntime",
|
|
||||||
"type": "general",
|
|
||||||
"code": {
|
|
||||||
"page": "https://localhost:3000/taskpane.html"
|
|
||||||
},
|
|
||||||
"lifetime": "short",
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"id": "TaskPaneRuntimeShow",
|
|
||||||
"type":"openPage",
|
|
||||||
"pinnable": false,
|
|
||||||
"view": "dashboard"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "CommandsRuntime",
|
|
||||||
"type": "general",
|
|
||||||
"code": {
|
|
||||||
"page": "https://localhost:3000/commands.html",
|
|
||||||
"script": "https://localhost:3000/commands.js"
|
|
||||||
},
|
|
||||||
"lifetime": "short",
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"id": "action",
|
|
||||||
"type": "executeFunction"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ribbons": [
|
|
||||||
{
|
|
||||||
"contexts": [
|
|
||||||
"mailCompose"
|
|
||||||
],
|
|
||||||
"tabs": [
|
|
||||||
{
|
|
||||||
"builtInTabId": "TabDefault",
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"id": "msgComposeGroup",
|
|
||||||
"label": "Contoso Add-in",
|
|
||||||
"icons": [
|
|
||||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
|
||||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
|
||||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
|
||||||
],
|
|
||||||
"controls": [
|
|
||||||
{
|
|
||||||
"id": "msgComposeOpenPaneButton",
|
|
||||||
"type": "button",
|
|
||||||
"label": "Show Task Pane",
|
|
||||||
"icons": [
|
|
||||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
|
||||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
|
||||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
|
||||||
],
|
|
||||||
"supertip": {
|
|
||||||
"title": "Show Task Pane",
|
|
||||||
"description": "Opens a task pane."
|
|
||||||
},
|
|
||||||
"actionId": "TaskPaneRuntimeShow"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "ActionButton",
|
|
||||||
"type": "button",
|
|
||||||
"label": "Perform an action",
|
|
||||||
"icons": [
|
|
||||||
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
|
|
||||||
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
|
|
||||||
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
|
|
||||||
],
|
|
||||||
"supertip": {
|
|
||||||
"title": "Perform an action",
|
|
||||||
"description": "Perform an action when clicked."
|
|
||||||
},
|
|
||||||
"actionId": "action"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
<Version>1.0.0.0</Version>
|
<Version>1.0.0.0</Version>
|
||||||
<ProviderName>Contoso</ProviderName>
|
<ProviderName>Contoso</ProviderName>
|
||||||
<DefaultLocale>en-US</DefaultLocale>
|
<DefaultLocale>en-US</DefaultLocale>
|
||||||
<DisplayName DefaultValue="addin"/>
|
<DisplayName DefaultValue="e-Court Documents Versioning"/>
|
||||||
<Description DefaultValue="A template to get started."/>
|
<Description DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||||
<IconUrl DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
<IconUrl DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||||
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||||
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
||||||
|
|
@ -22,6 +22,8 @@
|
||||||
<Sets DefaultMinVersion="1.1">
|
<Sets DefaultMinVersion="1.1">
|
||||||
<Set Name="WordApi" MinVersion="1.5"/>
|
<Set Name="WordApi" MinVersion="1.5"/>
|
||||||
<Set Name="DialogAPI" MinVersion="1.1"/>
|
<Set Name="DialogAPI" MinVersion="1.1"/>
|
||||||
|
<!-- เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
|
||||||
|
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
|
||||||
</Sets>
|
</Sets>
|
||||||
</Requirements>
|
</Requirements>
|
||||||
<DefaultSettings>
|
<DefaultSettings>
|
||||||
|
|
@ -82,12 +84,12 @@
|
||||||
</bt:Urls>
|
</bt:Urls>
|
||||||
<bt:ShortStrings>
|
<bt:ShortStrings>
|
||||||
<bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/>
|
<bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/>
|
||||||
<bt:String id="CommandsGroup.Label" DefaultValue="Commands Group"/>
|
<bt:String id="CommandsGroup.Label" DefaultValue="e-Court Add-in"/>
|
||||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Show Task Pane"/>
|
<bt:String id="TaskpaneButton.Label" DefaultValue="e-Court Documents Versioning"/>
|
||||||
</bt:ShortStrings>
|
</bt:ShortStrings>
|
||||||
<bt:LongStrings>
|
<bt:LongStrings>
|
||||||
<bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
|
<bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
|
||||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Click to Show a Task Pane"/>
|
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Opens the e-Court Documents Versioning task pane."/>
|
||||||
</bt:LongStrings>
|
</bt:LongStrings>
|
||||||
</Resources>
|
</Resources>
|
||||||
</VersionOverrides>
|
</VersionOverrides>
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
*/
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
keycloak: {
|
keycloak: {
|
||||||
/** เช่น http://localhost:8080/realms/word-versioning */
|
/** เช่น https://localhost:8443/realms/word-versioning (ต้องเป็น HTTPS เสมอ — Office Dialog API ปฏิเสธ http) */
|
||||||
authority: __KEYCLOAK_AUTHORITY__,
|
authority: __KEYCLOAK_AUTHORITY__,
|
||||||
/** public client ของ add-in (PKCE S256 บังคับ) */
|
/** public client ของ add-in (PKCE S256 บังคับ) */
|
||||||
clientId: __KEYCLOAK_CLIENT_ID__,
|
clientId: __KEYCLOAK_CLIENT_ID__,
|
||||||
},
|
},
|
||||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น http://localhost:5075 */
|
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น https://localhost:7232 (ต้องเป็น HTTPS — webview บล็อก http) */
|
||||||
apiBaseUrl: __API_BASE_URL__,
|
apiBaseUrl: __API_BASE_URL__,
|
||||||
/** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */
|
/** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */
|
||||||
get redirectUri(): string {
|
get redirectUri(): string {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import * as authService from "./authService";
|
||||||
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
|
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
|
||||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||||
|
|
||||||
const API_URL = "http://localhost:5075";
|
const API_URL = "https://localhost:7232";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService.signOut();
|
authService.signOut();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { createHash } from "crypto";
|
||||||
import * as authService from "./authService";
|
import * as authService from "./authService";
|
||||||
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||||
|
|
||||||
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token";
|
const TOKEN_ENDPOINT =
|
||||||
|
"https://localhost:8443/realms/word-versioning/protocol/openid-connect/token";
|
||||||
|
|
||||||
function base64Url(buffer: Buffer): string {
|
function base64Url(buffer: Buffer): string {
|
||||||
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
|
@ -110,7 +111,7 @@ describe("buildAuthorizationUrl", () => {
|
||||||
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
|
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(url.origin).toBe("http://localhost:8080");
|
expect(url.origin).toBe("https://localhost:8443");
|
||||||
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
|
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
|
||||||
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
|
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
|
||||||
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
|
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
|
||||||
|
|
@ -138,11 +139,11 @@ describe("decodeJwtExpiry", () => {
|
||||||
describe("login (Office Dialog + token exchange)", () => {
|
describe("login (Office Dialog + token exchange)", () => {
|
||||||
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
|
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
|
||||||
const office = installOfficeDialogMock();
|
const office = installOfficeDialogMock();
|
||||||
let exchangedBody: URLSearchParams | null = null;
|
const exchanged: { body?: URLSearchParams } = {};
|
||||||
const fetchMock = installFetchMock();
|
const fetchMock = installFetchMock();
|
||||||
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
|
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
|
||||||
expect(String(input)).toBe(TOKEN_ENDPOINT);
|
expect(String(input)).toBe(TOKEN_ENDPOINT);
|
||||||
exchangedBody = new URLSearchParams(String(init?.body));
|
exchanged.body = new URLSearchParams(String(init?.body));
|
||||||
return mockResponse({
|
return mockResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -169,10 +170,10 @@ describe("login (Office Dialog + token exchange)", () => {
|
||||||
expect(office.dialog.close).toHaveBeenCalled();
|
expect(office.dialog.close).toHaveBeenCalled();
|
||||||
|
|
||||||
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
|
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
|
||||||
const verifier = exchangedBody?.get("code_verifier") ?? "";
|
const verifier = exchanged.body?.get("code_verifier") ?? "";
|
||||||
expect(exchangedBody?.get("grant_type")).toBe("authorization_code");
|
expect(exchanged.body?.get("grant_type")).toBe("authorization_code");
|
||||||
expect(exchangedBody?.get("code")).toBe("the-auth-code");
|
expect(exchanged.body?.get("code")).toBe("the-auth-code");
|
||||||
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs");
|
expect(exchanged.body?.get("client_id")).toBe("word-addin-officejs");
|
||||||
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
|
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,32 @@ export function decodeJwtExpiry(token: string): number | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** อ่านชื่อผู้ใช้จาก access token ปัจจุบัน (`preferred_username` แล้ว fallback `sub`) — ใช้ใส่ใน XML history */
|
||||||
|
export function getUsername(): string | undefined {
|
||||||
|
if (!tokenState.accessToken) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parts = tokenState.accessToken.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(base64UrlDecode(parts[1])) as {
|
||||||
|
preferred_username?: unknown;
|
||||||
|
sub?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof payload.preferred_username === "string" && payload.preferred_username) {
|
||||||
|
return payload.preferred_username;
|
||||||
|
}
|
||||||
|
if (typeof payload.sub === "string" && payload.sub) {
|
||||||
|
return payload.sub;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State transitions
|
// State transitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
10
addin/src/services/errors.ts
Normal file
10
addin/src/services/errors.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
|
||||||
|
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === "string" && error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,28 @@ interface FileMockState {
|
||||||
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
|
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
|
||||||
function installOfficeFileMock(
|
function installOfficeFileMock(
|
||||||
slices: Uint8Array[],
|
slices: Uint8Array[],
|
||||||
options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {}
|
options: {
|
||||||
|
failSliceIndex?: number;
|
||||||
|
failClose?: boolean;
|
||||||
|
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
|
||||||
|
sliceDataForm?: "buffer" | "view" | "array" | "base64";
|
||||||
|
} = {}
|
||||||
): FileMockState {
|
): FileMockState {
|
||||||
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
|
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
|
||||||
|
|
||||||
|
const toSliceData = (bytes: Uint8Array): unknown => {
|
||||||
|
switch (options.sliceDataForm) {
|
||||||
|
case "view":
|
||||||
|
return bytes;
|
||||||
|
case "array":
|
||||||
|
return Array.from(bytes);
|
||||||
|
case "base64":
|
||||||
|
return Buffer.from(bytes).toString("base64");
|
||||||
|
default:
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const file = {
|
const file = {
|
||||||
sliceCount: slices.length,
|
sliceCount: slices.length,
|
||||||
getSliceAsync: (index: number, callback: (result: unknown) => void) => {
|
getSliceAsync: (index: number, callback: (result: unknown) => void) => {
|
||||||
|
|
@ -20,9 +38,10 @@ function installOfficeFileMock(
|
||||||
if (options.failSliceIndex === index) {
|
if (options.failSliceIndex === index) {
|
||||||
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
|
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
|
||||||
} else {
|
} else {
|
||||||
// host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่
|
callback({
|
||||||
const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer;
|
status: "succeeded",
|
||||||
callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } });
|
value: { index, data: toSliceData(slices[index]), size: slices[index].byteLength },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
closeAsync: (callback?: (result: unknown) => void) => {
|
closeAsync: (callback?: (result: unknown) => void) => {
|
||||||
|
|
@ -81,6 +100,26 @@ describe("getCurrentDocumentFile", () => {
|
||||||
expect(state.closeCalls).toBe(1);
|
expect(state.closeCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("slice มาเป็น byte array ธรรมดา number[] (Word บน Mac) ก็ประกอบได้", async () => {
|
||||||
|
const state = installOfficeFileMock([bytes(10, 20), bytes(30, 40)], { sliceDataForm: "array" });
|
||||||
|
|
||||||
|
const blob = await getCurrentDocumentFile();
|
||||||
|
|
||||||
|
expect(blob.size).toBe(4);
|
||||||
|
expect(state.closeCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slice มาเป็น base64 string ก็ decode แล้วประกอบได้", async () => {
|
||||||
|
const state = installOfficeFileMock([bytes(200, 100, 50), bytes(255)], {
|
||||||
|
sliceDataForm: "base64",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await getCurrentDocumentFile();
|
||||||
|
|
||||||
|
expect(blob.size).toBe(4);
|
||||||
|
expect(state.closeCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
|
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
|
||||||
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });
|
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,20 +35,48 @@ function getFileAsync(): Promise<Office.File> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */
|
/**
|
||||||
|
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบบแค่ "byte array"):
|
||||||
|
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
|
||||||
|
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
|
||||||
|
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
|
||||||
|
*/
|
||||||
|
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
const view = data as ArrayBufferView;
|
||||||
|
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
|
||||||
|
}
|
||||||
|
if (typeof data === "string") {
|
||||||
|
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
|
||||||
|
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
|
||||||
|
return Uint8Array.from(data);
|
||||||
|
}
|
||||||
|
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
|
||||||
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
file.getSliceAsync(sliceIndex, (result) => {
|
file.getSliceAsync(sliceIndex, (result) => {
|
||||||
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
if (result.status === Office.AsyncResultStatus.Succeeded) {
|
||||||
const data: unknown = result.value.data;
|
try {
|
||||||
if (data instanceof ArrayBuffer) {
|
resolve(sliceDataToBytes(result.value.data));
|
||||||
resolve(data);
|
} catch (error) {
|
||||||
} else if (ArrayBuffer.isView(data)) {
|
reject(
|
||||||
const view = data as ArrayBufferView;
|
error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
|
||||||
resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength));
|
);
|
||||||
} else {
|
|
||||||
reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)"));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
|
||||||
|
|
|
||||||
216
addin/src/services/versionFlow.test.ts
Normal file
216
addin/src/services/versionFlow.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import * as authService from "./authService";
|
||||||
|
import { ADDIN_SIGNATURE_GUID, buildVersionHistoryXml } from "./metadataService";
|
||||||
|
import { saveAndVersion, startVersioning } from "./versionFlow";
|
||||||
|
import { installWordMock } from "../test/wordMock";
|
||||||
|
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
||||||
|
|
||||||
|
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
|
||||||
|
function installOfficeDocumentMock(url: string | undefined): void {
|
||||||
|
const slice = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
(globalThis as unknown as Record<string, unknown>).Office = {
|
||||||
|
FileType: { Compressed: "compressed", Text: "text" },
|
||||||
|
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
||||||
|
context: {
|
||||||
|
document: {
|
||||||
|
url,
|
||||||
|
getFileAsync: (
|
||||||
|
_fileType: unknown,
|
||||||
|
_options: unknown,
|
||||||
|
callback: (result: unknown) => void
|
||||||
|
) => {
|
||||||
|
callback({
|
||||||
|
status: "succeeded",
|
||||||
|
value: {
|
||||||
|
sliceCount: 1,
|
||||||
|
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
|
||||||
|
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
|
||||||
|
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSession(): void {
|
||||||
|
authService.setSessionForTest(
|
||||||
|
makeFakeJwt(Date.now() + 600_000, "user-sub", { preferred_username: "tester" }),
|
||||||
|
"refresh-1",
|
||||||
|
Date.now() + 600_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUploadCall(call: [string, { body?: FormData }]): { url: URL; form: FormData } {
|
||||||
|
return { url: new URL(call[0]), form: call[1].body as FormData };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seedSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
authService.signOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("startVersioning (FR-1 / AC-5)", () => {
|
||||||
|
it("stamp รอบแรกครบ → save → upload v1 พร้อม fields + ชื่อไฟล์จาก URL → เขียน XML part v1", async () => {
|
||||||
|
const state = installWordMock({
|
||||||
|
contentControls: [
|
||||||
|
{ tag: "employee_name", text: "สมชาย ใจดี" },
|
||||||
|
{ tag: "salary", text: "50000" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
installOfficeDocumentMock("C:\\Users\\tester\\Documents\\สัญญาจ้าง.docx");
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({ status: 201, body: { versionId: "backend-version-1", versionNumber: 1 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await startVersioning();
|
||||||
|
|
||||||
|
// ผลลัพธ์ผูกกันทั้งชุด
|
||||||
|
expect(result).toEqual({
|
||||||
|
documentId: result.documentId,
|
||||||
|
versionId: "backend-version-1",
|
||||||
|
versionNumber: 1,
|
||||||
|
});
|
||||||
|
expect(result.documentId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
|
||||||
|
// ครบทั้ง 4 properties + SavedByAddIn = GUID ของ add-in
|
||||||
|
expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID);
|
||||||
|
expect(state.properties.DocumentTrackingId).toBe(result.documentId);
|
||||||
|
expect(state.properties.LastVersionId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
expect(state.properties.LastVersionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
// เรียก native save หนึ่งครั้ง (FR-2 ขั้นตอนที่ 4)
|
||||||
|
expect(state.saveCalls).toBe(1);
|
||||||
|
|
||||||
|
// upload ไปที่ documentId ที่ generate + แนบ fields + clientVersionId + ชื่อไฟล์จาก URL
|
||||||
|
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||||
|
expect(`${url.origin}${url.pathname}`).toBe(
|
||||||
|
`https://localhost:7232/api/documents/${result.documentId}/versions`
|
||||||
|
);
|
||||||
|
expect(String(form.get("clientVersionId"))).toBe(state.properties.LastVersionId);
|
||||||
|
expect(JSON.parse(String(form.get("fields")))).toEqual({
|
||||||
|
employee_name: "สมชาย ใจดี",
|
||||||
|
salary: "50000",
|
||||||
|
});
|
||||||
|
expect(String((form.get("file") as File).name)).toBe("สัญญาจ้าง.docx");
|
||||||
|
|
||||||
|
// XML part มี entry v1 ผูกกับ clientVersionId เดียวกับ LastVersionId
|
||||||
|
expect(state.xmlParts).toHaveLength(1);
|
||||||
|
expect(state.xmlParts[0].xml).toContain('id="');
|
||||||
|
expect(state.xmlParts[0].xml).toContain('number="1"');
|
||||||
|
expect(state.xmlParts[0].xml).toContain('user="tester"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL ไม่มี (Word Online) → ชื่อไฟล์ fallback", async () => {
|
||||||
|
installWordMock({});
|
||||||
|
installOfficeDocumentMock(undefined);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await startVersioning();
|
||||||
|
|
||||||
|
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||||
|
expect(String((form.get("file") as File).name)).toMatch(/\.docx$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
|
||||||
|
it("stamp → append XML entry (เดาเลขถัดไป) → save → upload พร้อม fields ล่าสุด", async () => {
|
||||||
|
const state = installWordMock({
|
||||||
|
properties: {
|
||||||
|
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||||
|
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
LastVersionId: "old-client-version-id",
|
||||||
|
},
|
||||||
|
xmlParts: [
|
||||||
|
buildVersionHistoryXml([
|
||||||
|
{
|
||||||
|
id: "old-client-version-id",
|
||||||
|
number: 1,
|
||||||
|
timestamp: "2026-08-19T09:00:00Z",
|
||||||
|
user: "tester",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
contentControls: [{ tag: "salary", text: "60000" }],
|
||||||
|
});
|
||||||
|
installOfficeDocumentMock(undefined);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
|
||||||
|
|
||||||
|
expect(result).toEqual({ versionId: "backend-version-2", versionNumber: 2 });
|
||||||
|
|
||||||
|
// properties: tracking id คงเดิม, LastVersionId เปลี่ยนเป็น clientVersionId ใหม่
|
||||||
|
expect(state.properties.DocumentTrackingId).toBe("11111111-1111-1111-1111-111111111111");
|
||||||
|
const newLastVersionId = state.properties.LastVersionId;
|
||||||
|
expect(newLastVersionId).not.toBe("old-client-version-id");
|
||||||
|
expect(state.saveCalls).toBe(1);
|
||||||
|
|
||||||
|
// XML part: 2 entries เรียง v2 ก่อน v1
|
||||||
|
expect(state.xmlParts).toHaveLength(1);
|
||||||
|
const xml = state.xmlParts[0].xml;
|
||||||
|
expect(xml).toContain('number="2"');
|
||||||
|
expect(xml).toContain('number="1"');
|
||||||
|
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"'));
|
||||||
|
|
||||||
|
// upload ไปยัง document เดิม + fields ล่าสุด
|
||||||
|
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
|
||||||
|
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
|
||||||
|
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
|
||||||
|
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {
|
||||||
|
const state = installWordMock({
|
||||||
|
properties: {
|
||||||
|
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||||
|
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
LastVersionId: "old",
|
||||||
|
},
|
||||||
|
xmlParts: [buildVersionHistoryXml([{ id: "old", number: 1, timestamp: "t", user: "u" }])],
|
||||||
|
});
|
||||||
|
installOfficeDocumentMock(undefined);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({ status: 201, body: { versionId: "backend-version-5", versionNumber: 5 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
|
||||||
|
|
||||||
|
expect(result.versionNumber).toBe(5);
|
||||||
|
// entry ใหม่ถูกแก้จาก 2 (ที่เดา) เป็น 5 (ของจริง)
|
||||||
|
const xml = state.xmlParts[0].xml;
|
||||||
|
expect(xml).toContain('number="5"');
|
||||||
|
expect(xml).not.toContain('number="2"');
|
||||||
|
expect(xml).toContain('number="1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("XML part เขียนไม่ได้ (known bug บน Mac) → flow ยังสำเร็จต่อ (AC-10)", async () => {
|
||||||
|
installWordMock({
|
||||||
|
properties: {
|
||||||
|
SavedByAddIn: ADDIN_SIGNATURE_GUID,
|
||||||
|
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
},
|
||||||
|
failXmlAdd: true,
|
||||||
|
});
|
||||||
|
installOfficeDocumentMock(undefined);
|
||||||
|
const fetchMock = installFetchMock();
|
||||||
|
fetchMock.mockImplementationOnce(async () =>
|
||||||
|
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(saveAndVersion("11111111-1111-1111-1111-111111111111", 1)).resolves.toEqual({
|
||||||
|
versionId: "backend-version-2",
|
||||||
|
versionNumber: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
159
addin/src/services/versionFlow.ts
Normal file
159
addin/src/services/versionFlow.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/* global Word, Office */
|
||||||
|
|
||||||
|
import * as authService from "./authService";
|
||||||
|
import { getCurrentDocumentFile } from "./fileService";
|
||||||
|
import {
|
||||||
|
generateGuid,
|
||||||
|
readContentControlFields,
|
||||||
|
readVersionHistoryFromXml,
|
||||||
|
stampTrackedProperties,
|
||||||
|
writeVersionHistoryToXml,
|
||||||
|
XmlVersionEntry,
|
||||||
|
} from "./metadataService";
|
||||||
|
import { uploadVersion } from "./versioningService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
|
||||||
|
*
|
||||||
|
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
|
||||||
|
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
|
||||||
|
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save()
|
||||||
|
* → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history
|
||||||
|
*
|
||||||
|
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
|
||||||
|
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
|
||||||
|
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
|
||||||
|
* (backend คือ source of truth ของ id จริงอยู่ดี)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StartVersioningResult {
|
||||||
|
documentId: string;
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveAndVersionResult {
|
||||||
|
versionId: string;
|
||||||
|
versionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** เลข version ถัดไปตามที่ client เดา (history ล่าสุด + 1) — ใช้เขียน XML part ก่อนรู้ค่าจริงจาก backend */
|
||||||
|
function predictNextVersionNumber(latestVersionNumber: number): number {
|
||||||
|
return latestVersionNumber + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
|
||||||
|
function guessDocumentFileName(): string | undefined {
|
||||||
|
try {
|
||||||
|
const url = Office.context.document.url;
|
||||||
|
if (!url) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const name = url.split(/[\\/]/).pop();
|
||||||
|
if (!name) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `context.document.save(Word.SaveBehavior.save)` — trigger native save ตาม FR-2 ขั้นตอนที่ 4 */
|
||||||
|
async function saveDocument(): Promise<void> {
|
||||||
|
await Word.run(async (context) => {
|
||||||
|
context.document.save(Word.SaveBehavior.save);
|
||||||
|
await context.sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCurrentFile(
|
||||||
|
documentId: string,
|
||||||
|
clientVersionId: string,
|
||||||
|
fields: Record<string, string>
|
||||||
|
): Promise<{ versionId: string; versionNumber: number }> {
|
||||||
|
const file = await getCurrentDocumentFile();
|
||||||
|
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 */
|
||||||
|
export async function startVersioning(
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): Promise<StartVersioningResult> {
|
||||||
|
const documentId = generateGuid();
|
||||||
|
const clientVersionId = generateGuid();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
|
||||||
|
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
|
||||||
|
await stampTrackedProperties({
|
||||||
|
documentTrackingId: documentId,
|
||||||
|
lastVersionId: clientVersionId,
|
||||||
|
lastVersionTimestamp: timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) XML part (secondary — fail ได้เงียบๆ)
|
||||||
|
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||||
|
await writeVersionHistoryToXml([
|
||||||
|
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3) native save แล้ว capture ไฟล์ + fields
|
||||||
|
onProgress?.("กำลังบันทึกไฟล์…");
|
||||||
|
await saveDocument();
|
||||||
|
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||||
|
const fields = await readContentControlFields();
|
||||||
|
|
||||||
|
// 4) upload version แรก
|
||||||
|
onProgress?.("กำลังอัปโหลด version 1…");
|
||||||
|
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
|
||||||
|
return { documentId, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว) */
|
||||||
|
export async function saveAndVersion(
|
||||||
|
documentId: string,
|
||||||
|
latestVersionNumber: number,
|
||||||
|
onProgress?: (message: string) => void
|
||||||
|
): Promise<SaveAndVersionResult> {
|
||||||
|
const clientVersionId = generateGuid();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const predictedNumber = predictNextVersionNumber(latestVersionNumber);
|
||||||
|
const user = authService.getUsername() ?? "unknown";
|
||||||
|
|
||||||
|
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
|
||||||
|
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
|
||||||
|
await stampTrackedProperties({
|
||||||
|
lastVersionId: clientVersionId,
|
||||||
|
lastVersionTimestamp: timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
|
||||||
|
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
|
||||||
|
const entries = await readVersionHistoryFromXml();
|
||||||
|
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
|
||||||
|
await writeVersionHistoryToXml(entries);
|
||||||
|
|
||||||
|
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
|
||||||
|
onProgress?.("กำลังบันทึกไฟล์…");
|
||||||
|
await saveDocument();
|
||||||
|
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
|
||||||
|
const fields = await readContentControlFields();
|
||||||
|
|
||||||
|
// 4) upload
|
||||||
|
onProgress?.("กำลังอัปโหลด version ใหม่…");
|
||||||
|
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
|
||||||
|
|
||||||
|
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
|
||||||
|
if (result.versionNumber !== predictedNumber) {
|
||||||
|
const corrected = await readVersionHistoryFromXml();
|
||||||
|
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
|
||||||
|
if (entry) {
|
||||||
|
entry.number = result.versionNumber;
|
||||||
|
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
|
||||||
|
await writeVersionHistoryToXml(corrected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,26 @@ export function buildDownloadUrl(documentId: string, versionId: string): string
|
||||||
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
|
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionDownloadLink {
|
||||||
|
url: string;
|
||||||
|
/** ISO-8601 เวลาที่ URL หมดอายุ */
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
|
||||||
|
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
|
||||||
|
*/
|
||||||
|
export async function getDownloadLink(
|
||||||
|
documentId: string,
|
||||||
|
versionId: string
|
||||||
|
): Promise<VersionDownloadLink> {
|
||||||
|
return apiGetJson<VersionDownloadLink>(
|
||||||
|
`/api/documents/${documentId}/versions/${versionId}/download-link`,
|
||||||
|
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
|
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
|
||||||
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
|
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
|
||||||
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
|
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,430 @@
|
||||||
import * as React from "react";
|
/* global Word, Office */
|
||||||
import Header from "./Header";
|
|
||||||
import HeroList, { HeroListItem } from "./HeroList";
|
|
||||||
import TextInsertion from "./TextInsertion";
|
|
||||||
import { makeStyles } from "@fluentui/react-components";
|
|
||||||
import { Ribbon24Regular, LockOpen24Regular, DesignIdeas24Regular } from "@fluentui/react-icons";
|
|
||||||
import { insertText } from "../taskpane";
|
|
||||||
|
|
||||||
interface AppProps {
|
import * as React from "react";
|
||||||
title: string;
|
import {
|
||||||
}
|
Button,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
MessageBarActions,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { DocumentArrowUp24Regular, Save24Regular, SignOut24Regular } from "@fluentui/react-icons";
|
||||||
|
import * as authService from "../../services/authService";
|
||||||
|
import { AuthRequiredError } from "../../services/apiClient";
|
||||||
|
import { getErrorMessage } from "../../services/errors";
|
||||||
|
import * as metadataService from "../../services/metadataService";
|
||||||
|
import { downloadVersion, getDownloadLink, getVersions } from "../../services/versioningService";
|
||||||
|
import { saveAndVersion, startVersioning } from "../../services/versionFlow";
|
||||||
|
import { VersionRecord } from "../../models/versionRecord";
|
||||||
|
import ConfirmVersionDialog from "./ConfirmVersionDialog";
|
||||||
|
import VersionHistoryPanel from "./VersionHistoryPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task pane หลัก — state machine ตาม Phase 5:
|
||||||
|
* checking → (ไม่มี token) signed-out
|
||||||
|
* → (มี token + ไม่ใช่ tracked document) untracked
|
||||||
|
* → (มี token + tracked document) tracking
|
||||||
|
*
|
||||||
|
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9)
|
||||||
|
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
|
||||||
|
|
||||||
|
/** FR-3 — ถามเอกสารทุก 30 วินาทีว่ามีแก้ไขที่ยังไม่ได้ save ไหม */
|
||||||
|
const UNSAVED_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
|
padding: tokens.spacingVerticalM,
|
||||||
|
gap: tokens.spacingVerticalS,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingBottom: tokens.spacingVerticalS,
|
||||||
|
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: tokens.spacingVerticalS,
|
||||||
|
paddingTop: tokens.spacingVerticalM,
|
||||||
|
},
|
||||||
|
checking: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingVerticalM,
|
||||||
|
paddingTop: tokens.spacingVerticalXL,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const App: React.FC<AppProps> = (props: AppProps) => {
|
const App: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
// The list items are static and won't change at runtime,
|
|
||||||
// so this should be an ordinary const, not a part of state.
|
const [phase, setPhase] = React.useState<Phase>("checking");
|
||||||
const listItems: HeroListItem[] = [
|
const [bootError, setBootError] = React.useState<string | null>(null);
|
||||||
{
|
const [username, setUsername] = React.useState<string | null>(null);
|
||||||
icon: <Ribbon24Regular />,
|
|
||||||
primaryText: "Achieve more with Office integration",
|
const [documentId, setDocumentId] = React.useState<string | null>(null);
|
||||||
|
const [versions, setVersions] = React.useState<VersionRecord[]>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = React.useState(false);
|
||||||
|
const [historyError, setHistoryError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [signingIn, setSigningIn] = React.useState(false);
|
||||||
|
const [loginError, setLoginError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [starting, setStarting] = React.useState(false);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [progressMessage, setProgressMessage] = React.useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||||
|
const [flowError, setFlowError] = React.useState<string | null>(null);
|
||||||
|
const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [downloadingVersionId, setDownloadingVersionId] = React.useState<string | null>(null);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
|
||||||
|
|
||||||
|
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */
|
||||||
|
const handleAuthRequired = React.useCallback((error: unknown): boolean => {
|
||||||
|
if (error instanceof AuthRequiredError) {
|
||||||
|
setPhase("signed-out");
|
||||||
|
setDocumentId(null);
|
||||||
|
setVersions([]);
|
||||||
|
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshHistory = React.useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
setHistoryError(null);
|
||||||
|
try {
|
||||||
|
setVersions(await getVersions(id));
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
[handleAuthRequired]
|
||||||
icon: <LockOpen24Regular />,
|
);
|
||||||
primaryText: "Unlock features and functionality",
|
|
||||||
},
|
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history */
|
||||||
{
|
const detect = React.useCallback(async () => {
|
||||||
icon: <DesignIdeas24Regular />,
|
setPhase("checking");
|
||||||
primaryText: "Create and visualize like a pro",
|
setBootError(null);
|
||||||
},
|
try {
|
||||||
];
|
const token = await authService.getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setPhase("signed-out");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authService.startAutoRefresh();
|
||||||
|
setUsername(authService.getUsername() ?? null);
|
||||||
|
|
||||||
|
const tracked = await metadataService.isTrackedDocument();
|
||||||
|
if (!tracked) {
|
||||||
|
setPhase("untracked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const properties = await metadataService.readTrackedProperties();
|
||||||
|
const id = properties.documentTrackingId;
|
||||||
|
if (!id) {
|
||||||
|
// mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked
|
||||||
|
setPhase("untracked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDocumentId(id);
|
||||||
|
setPhase("tracking");
|
||||||
|
await refreshHistory(id);
|
||||||
|
} catch (error) {
|
||||||
|
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
}, [refreshHistory]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void detect();
|
||||||
|
}, [detect]);
|
||||||
|
|
||||||
|
/** FR-3 — poll `document.saved` เฉพาะตอน tracking */
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (phase !== "tracking") {
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const unsaved = await Word.run(async (context) => {
|
||||||
|
const document = context.document;
|
||||||
|
document.load("saved");
|
||||||
|
await context.sync();
|
||||||
|
return !document.saved;
|
||||||
|
});
|
||||||
|
setHasUnsavedChanges(unsaved);
|
||||||
|
} catch {
|
||||||
|
// อ่านไม่ได้รอบนี้ก็ข้าม — banner เป็นแค่ soft reminder
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, UNSAVED_CHECK_INTERVAL_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setVersions([]);
|
||||||
|
setLoginError(null);
|
||||||
|
setPhase("signed-out");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) */
|
||||||
|
const handleStartVersioning = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
setFlowError(null);
|
||||||
|
setFlowSuccess(null);
|
||||||
|
try {
|
||||||
|
const result = await startVersioning(setProgressMessage);
|
||||||
|
setDocumentId(result.documentId);
|
||||||
|
setPhase("tracking");
|
||||||
|
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
|
||||||
|
await refreshHistory(result.documentId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setProgressMessage(null);
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload */
|
||||||
|
const handleSaveAndVersion = async () => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFlowError(null);
|
||||||
|
setFlowSuccess(null);
|
||||||
|
try {
|
||||||
|
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
|
||||||
|
const result = await saveAndVersion(documentId, latestVersionNumber, setProgressMessage);
|
||||||
|
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
|
||||||
|
await refreshHistory(documentId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setProgressMessage(null);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
|
||||||
|
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
|
||||||
|
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) · host ไหนไม่มี openBrowserWindow จะย้อนกลับไปใช้ blob
|
||||||
|
*/
|
||||||
|
const handleDownload = async (version: VersionRecord) => {
|
||||||
|
if (!documentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDownloadingVersionId(version.versionId);
|
||||||
|
setFlowError(null);
|
||||||
|
try {
|
||||||
|
const link = await getDownloadLink(documentId, version.versionId);
|
||||||
|
if (typeof Office.context.ui.openBrowserWindow === "function") {
|
||||||
|
Office.context.ui.openBrowserWindow(link.url);
|
||||||
|
setFlowSuccess(`กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`);
|
||||||
|
} else {
|
||||||
|
const blob = await downloadVersion(documentId, version.versionId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `document_v${version.versionNumber}.docx`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!handleAuthRequired(error)) {
|
||||||
|
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloadingVersionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<Header logo="assets/logo-filled.png" title={props.title} message="Welcome" />
|
<div className={styles.header}>
|
||||||
<HeroList message="Discover what this add-in can do for you today!" items={listItems} />
|
<Text weight="semibold">e-Court Documents Versioning</Text>
|
||||||
<TextInsertion insertText={insertText} />
|
{phase !== "signed-out" && phase !== "checking" && username ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<SignOut24Regular />}
|
||||||
|
onClick={handleSignOut}
|
||||||
|
>
|
||||||
|
{username} · ออกจากระบบ
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "checking" ? (
|
||||||
|
<div className={styles.checking}>
|
||||||
|
<Spinner label="กำลังตรวจสอบเอกสาร…" />
|
||||||
|
{bootError ? (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{bootError}</MessageBarBody>
|
||||||
|
<MessageBarActions>
|
||||||
|
<Button size="small" appearance="outline" onClick={() => void detect()}>
|
||||||
|
ลองใหม่
|
||||||
|
</Button>
|
||||||
|
</MessageBarActions>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "signed-out" ? (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<Text size={300}>เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</Text>
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
|
||||||
|
</Text>
|
||||||
|
<Button appearance="primary" disabled={signingIn} onClick={() => void handleSignIn()}>
|
||||||
|
{signingIn ? "กำลังเข้าสู่ระบบ…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
{loginError ? (
|
||||||
|
<MessageBar intent="warning">
|
||||||
|
<MessageBarBody>{loginError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "untracked" ? (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<Text size={300}>เอกสารนี้ยังไม่ได้ติดตาม version</Text>
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
|
||||||
|
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<DocumentArrowUp24Regular />}
|
||||||
|
disabled={starting}
|
||||||
|
onClick={() => void handleStartVersioning()}
|
||||||
|
>
|
||||||
|
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
|
||||||
|
</Button>
|
||||||
|
{progressMessage ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Spinner size="tiny" />
|
||||||
|
<Text size={200}>{progressMessage}</Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{flowError ? (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{flowError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{phase === "tracking" ? (
|
||||||
|
<div className={styles.section}>
|
||||||
|
{hasUnsavedChanges ? (
|
||||||
|
<MessageBar intent="warning">
|
||||||
|
<MessageBarBody>
|
||||||
|
มีการแก้ไขที่ยังไม่ได้ version — กด Save & Version เพื่อบันทึกเป็น version ใหม่
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{flowSuccess ? (
|
||||||
|
<MessageBar intent="success">
|
||||||
|
<MessageBarBody>{flowSuccess}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
{flowError ? (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{flowError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<Save24Regular />}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Save & Version
|
||||||
|
</Button>
|
||||||
|
{saving && progressMessage ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Spinner size="tiny" />
|
||||||
|
<Text size={200}>{progressMessage}</Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
tracking id: {documentId?.slice(0, 8)}…
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<VersionHistoryPanel
|
||||||
|
versions={versions}
|
||||||
|
loading={historyLoading}
|
||||||
|
error={historyError}
|
||||||
|
downloadingVersionId={downloadingVersionId}
|
||||||
|
onDownload={(version) => void handleDownload(version)}
|
||||||
|
onRetry={() => documentId && void refreshHistory(documentId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmVersionDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
busy={saving}
|
||||||
|
onConfirm={() => void handleSaveAndVersion()}
|
||||||
|
onDismiss={() => setConfirmOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
55
addin/src/taskpane/components/ConfirmVersionDialog.tsx
Normal file
55
addin/src/taskpane/components/ConfirmVersionDialog.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogSurface,
|
||||||
|
DialogTitle,
|
||||||
|
Spinner,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
|
||||||
|
interface ConfirmVersionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่นทั้งสอง disable + แสดง spinner */
|
||||||
|
busy: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" */
|
||||||
|
export const ConfirmVersionDialog: React.FC<ConfirmVersionDialogProps> = ({
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
onConfirm,
|
||||||
|
onDismiss,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(_event, data) => !data.open && !busy && onDismiss()} modalType="alert">
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>สร้าง version ใหม่?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้
|
||||||
|
{busy ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
|
||||||
|
<Spinner size="tiny" /> กำลังดำเนินการ…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button appearance="secondary" disabled={busy} onClick={onDismiss}>
|
||||||
|
ยกเลิก
|
||||||
|
</Button>
|
||||||
|
<Button appearance="primary" disabled={busy} onClick={onConfirm}>
|
||||||
|
สร้าง version
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmVersionDialog;
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { Image, tokens, makeStyles } from "@fluentui/react-components";
|
|
||||||
|
|
||||||
export interface HeaderProps {
|
|
||||||
title: string;
|
|
||||||
logo: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
|
||||||
welcome__header: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingBottom: "30px",
|
|
||||||
paddingTop: "100px",
|
|
||||||
backgroundColor: tokens.colorNeutralBackground3,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
fontSize: tokens.fontSizeHero900,
|
|
||||||
fontWeight: tokens.fontWeightRegular,
|
|
||||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
|
||||||
const { title, logo, message } = props;
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={styles.welcome__header}>
|
|
||||||
<Image width="90" height="90" src={logo} alt={title} />
|
|
||||||
<h1 className={styles.message}>{message}</h1>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { tokens, makeStyles } from "@fluentui/react-components";
|
|
||||||
|
|
||||||
export interface HeroListItem {
|
|
||||||
icon: React.JSX.Element;
|
|
||||||
primaryText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeroListProps {
|
|
||||||
message: string;
|
|
||||||
items: HeroListItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
|
||||||
list: {
|
|
||||||
marginTop: "20px",
|
|
||||||
},
|
|
||||||
listItem: {
|
|
||||||
paddingBottom: "20px",
|
|
||||||
display: "flex",
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
marginRight: "10px",
|
|
||||||
},
|
|
||||||
itemText: {
|
|
||||||
fontSize: tokens.fontSizeBase300,
|
|
||||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
|
||||||
},
|
|
||||||
welcome__main: {
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
fontSize: tokens.fontSizeBase500,
|
|
||||||
fontColor: tokens.colorNeutralBackgroundStatic,
|
|
||||||
fontWeight: tokens.fontWeightRegular,
|
|
||||||
paddingLeft: "10px",
|
|
||||||
paddingRight: "10px",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const HeroList: React.FC<HeroListProps> = (props: HeroListProps) => {
|
|
||||||
const { items, message } = props;
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
const listItems = items.map((item, index) => (
|
|
||||||
<li className={styles.listItem} key={index}>
|
|
||||||
<i className={styles.icon}>{item.icon}</i>
|
|
||||||
<span className={styles.itemText}>{item.primaryText}</span>
|
|
||||||
</li>
|
|
||||||
));
|
|
||||||
return (
|
|
||||||
<div className={styles.welcome__main}>
|
|
||||||
<h2 className={styles.message}>{message}</h2>
|
|
||||||
<ul className={styles.list}>{listItems}</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeroList;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-components";
|
|
||||||
|
|
||||||
/* global HTMLTextAreaElement */
|
|
||||||
|
|
||||||
interface TextInsertionProps {
|
|
||||||
insertText: (_text: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
|
||||||
instructions: {
|
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
|
||||||
marginTop: "20px",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
textPromptAndInsertion: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
textAreaField: {
|
|
||||||
marginLeft: "20px",
|
|
||||||
marginTop: "30px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
marginRight: "20px",
|
|
||||||
maxWidth: "50%",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const TextInsertion: React.FC<TextInsertionProps> = (props: TextInsertionProps) => {
|
|
||||||
const [text, setText] = useState<string>("Some text.");
|
|
||||||
|
|
||||||
const handleTextInsertion = async () => {
|
|
||||||
await props.insertText(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTextChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setText(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.textPromptAndInsertion}>
|
|
||||||
<Field className={styles.textAreaField} size="large" label="Enter text to be inserted into the document.">
|
|
||||||
<Textarea size="large" value={text} onChange={handleTextChange} />
|
|
||||||
</Field>
|
|
||||||
<Field className={styles.instructions}>Click the button to insert text.</Field>
|
|
||||||
<Button appearance="primary" disabled={false} size="large" onClick={handleTextInsertion}>
|
|
||||||
Insert text
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TextInsertion;
|
|
||||||
128
addin/src/taskpane/components/VersionHistoryPanel.tsx
Normal file
128
addin/src/taskpane/components/VersionHistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { ArrowDownload24Regular, History24Regular } from "@fluentui/react-icons";
|
||||||
|
import { VersionRecord } from "../../models/versionRecord";
|
||||||
|
|
||||||
|
interface VersionHistoryPanelProps {
|
||||||
|
versions: VersionRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
|
||||||
|
downloadingVersionId: string | null;
|
||||||
|
onDownload: (version: VersionRecord) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: tokens.spacingVerticalS,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingHorizontalXS,
|
||||||
|
marginTop: tokens.spacingVerticalL,
|
||||||
|
marginBottom: tokens.spacingVerticalXXS,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
":hover": {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemInfo: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? iso
|
||||||
|
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น */
|
||||||
|
export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
downloadingVersionId,
|
||||||
|
onDownload,
|
||||||
|
onRetry,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<History24Regular />
|
||||||
|
<Text weight="semibold">Version history</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <Spinner size="small" label="กำลังโหลด…" /> : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>
|
||||||
|
{error}{" "}
|
||||||
|
<Button size="small" appearance="subtle" onClick={onRetry}>
|
||||||
|
ลองใหม่
|
||||||
|
</Button>
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && !error && versions.length === 0 ? (
|
||||||
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
ยังไม่มี version ในระบบ
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{versions.map((version) => (
|
||||||
|
<div className={styles.item} key={version.versionId}>
|
||||||
|
<div className={styles.itemInfo}>
|
||||||
|
<Badge appearance="filled" color="brand">
|
||||||
|
v{version.versionNumber}
|
||||||
|
</Badge>
|
||||||
|
<span className={styles.metaRow}>{formatTimestamp(version.createdAt)}</span>
|
||||||
|
<span className={styles.metaRow}>โดย {version.createdByUserId}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowDownload24Regular />}
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
disabled={downloadingVersionId !== null}
|
||||||
|
onClick={() => onDownload(version)}
|
||||||
|
>
|
||||||
|
{downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionHistoryPanel;
|
||||||
|
|
@ -5,8 +5,6 @@ import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
|
||||||
/* global document, Office, module, require, HTMLElement */
|
/* global document, Office, module, require, HTMLElement */
|
||||||
|
|
||||||
const title = "Contoso Task Pane Add-in";
|
|
||||||
|
|
||||||
const rootElement: HTMLElement | null = document.getElementById("container");
|
const rootElement: HTMLElement | null = document.getElementById("container");
|
||||||
const root = rootElement ? createRoot(rootElement) : undefined;
|
const root = rootElement ? createRoot(rootElement) : undefined;
|
||||||
|
|
||||||
|
|
@ -14,7 +12,7 @@ const root = rootElement ? createRoot(rootElement) : undefined;
|
||||||
Office.onReady(() => {
|
Office.onReady(() => {
|
||||||
root?.render(
|
root?.render(
|
||||||
<FluentProvider theme={webLightTheme}>
|
<FluentProvider theme={webLightTheme}>
|
||||||
<App title={title} />
|
<App />
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Contoso Task Pane Add-in</title>
|
<title>e-Court Documents Versioning</title>
|
||||||
|
|
||||||
<!-- Office JavaScript API -->
|
<!-- Office JavaScript API -->
|
||||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
/* global Word console */
|
|
||||||
|
|
||||||
export async function insertText(text: string) {
|
|
||||||
// Write text to the document.
|
|
||||||
try {
|
|
||||||
await Word.run(async (context) => {
|
|
||||||
let body = context.document.body;
|
|
||||||
body.insertParagraph(text, Word.InsertLocation.end);
|
|
||||||
await context.sync();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error: " + error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -36,11 +36,17 @@ export function installFetchMock(
|
||||||
return fetchMock;
|
return fetchMock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` */
|
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` / `sub` / `preferred_username` */
|
||||||
export function makeFakeJwt(expiresAtMs: number, subject = "user-1"): string {
|
export function makeFakeJwt(
|
||||||
|
expiresAtMs: number,
|
||||||
|
subject = "user-1",
|
||||||
|
extraClaims: Record<string, unknown> = {}
|
||||||
|
): string {
|
||||||
const encode = (value: string): string =>
|
const encode = (value: string): string =>
|
||||||
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||||
const payload = encode(JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000) }));
|
const payload = encode(
|
||||||
|
JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000), ...extraClaims })
|
||||||
|
);
|
||||||
return `${header}.${payload}.${encode("fake-signature")}`;
|
return `${header}.${payload}.${encode("fake-signature")}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,6 @@ if (typeof globalAny.TextEncoder === "undefined") {
|
||||||
globalAny.TextDecoder = util.TextDecoder;
|
globalAny.TextDecoder = util.TextDecoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
globalAny.__KEYCLOAK_AUTHORITY__ = "http://localhost:8080/realms/word-versioning";
|
globalAny.__KEYCLOAK_AUTHORITY__ = "https://localhost:8443/realms/word-versioning";
|
||||||
globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs";
|
globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs";
|
||||||
globalAny.__API_BASE_URL__ = "http://localhost:5075";
|
globalAny.__API_BASE_URL__ = "https://localhost:7232";
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export interface WordMockState {
|
||||||
/** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */
|
/** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */
|
||||||
xmlParts: Array<{ xml: string; namespace: string }>;
|
xmlParts: Array<{ xml: string; namespace: string }>;
|
||||||
contentControls: Array<{ tag: string; text: string }>;
|
contentControls: Array<{ tag: string; text: string }>;
|
||||||
|
/** จำนวนครั้งที่โค้ดเรียก context.document.save() */
|
||||||
|
saveCalls: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
||||||
|
|
@ -34,6 +36,7 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
||||||
// part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service)
|
// part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service)
|
||||||
xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })),
|
xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })),
|
||||||
contentControls: (options.contentControls ?? []).map((control) => ({ ...control })),
|
contentControls: (options.contentControls ?? []).map((control) => ({ ...control })),
|
||||||
|
saveCalls: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeProperty = (key: string): unknown => {
|
const makeProperty = (key: string): unknown => {
|
||||||
|
|
@ -93,6 +96,11 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
||||||
},
|
},
|
||||||
// ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4)
|
// ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4)
|
||||||
customXmlParts,
|
customXmlParts,
|
||||||
|
save: (_saveBehavior?: unknown) => {
|
||||||
|
state.saveCalls++;
|
||||||
|
},
|
||||||
|
/** FR-3 poll อ่านค่านี้ — default ไม่มีแก้ไขค้าง */
|
||||||
|
saved: true,
|
||||||
contentControls: {
|
contentControls: {
|
||||||
get items() {
|
get items() {
|
||||||
return state.contentControls.map((control) => ({ tag: control.tag, text: control.text }));
|
return state.contentControls.map((control) => ({ tag: control.tag, text: control.text }));
|
||||||
|
|
@ -107,6 +115,7 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
|
||||||
(globalThis as unknown as Record<string, unknown>).Word = {
|
(globalThis as unknown as Record<string, unknown>).Word = {
|
||||||
run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> =>
|
run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> =>
|
||||||
batch(context as unknown as Word.RequestContext),
|
batch(context as unknown as Word.RequestContext),
|
||||||
|
SaveBehavior: { save: "Save", prompt: "Prompt" },
|
||||||
} as unknown as typeof Word;
|
} as unknown as typeof Word;
|
||||||
|
|
||||||
// กันชัดว่า mock ตรงกับ namespace ที่ service ใช้
|
// กันชัดว่า mock ตรงกับ namespace ที่ service ใช้
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@ const urlDev = "https://localhost:3000/";
|
||||||
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
|
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
|
||||||
|
|
||||||
// config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev
|
// config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev
|
||||||
const keycloakAuthority = process.env.KEYCLOAK_AUTHORITY || "http://localhost:8080/realms/word-versioning";
|
// Keycloak ต้องเป็น HTTPS เสมอ: Office Dialog API ปฏิเสธ http:// ("The HTTP protocol is not supported")
|
||||||
|
const keycloakAuthority =
|
||||||
|
process.env.KEYCLOAK_AUTHORITY || "https://localhost:8443/realms/word-versioning";
|
||||||
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs";
|
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs";
|
||||||
const apiBaseUrl = (process.env.API_BASE_URL || "http://localhost:5075").replace(/\/$/, "");
|
// backend ต้องเป็น HTTPS ด้วยเหมือนกัน — webview ของ Word บล็อก fetch http:// จากหน้า https (mixed content)
|
||||||
|
const apiBaseUrl = (process.env.API_BASE_URL || "https://localhost:7232").replace(/\/$/, "");
|
||||||
|
|
||||||
async function getHttpsOptions() {
|
async function getHttpsOptions() {
|
||||||
const httpsOptions = await devCerts.getHttpsServerOptions();
|
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,33 @@ public class DocumentsController(
|
||||||
return File(stream, DocxContentType, downloadName);
|
return File(stream, DocxContentType, downloadName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต)
|
||||||
|
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
|
||||||
|
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
|
||||||
|
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
|
||||||
|
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink(
|
||||||
|
Guid documentId, Guid versionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var objectKey = await db.DocumentVersions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => v.DocumentId == documentId && v.Id == versionId)
|
||||||
|
.Select(v => v.ObjectKey)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (objectKey is null)
|
||||||
|
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
|
||||||
|
|
||||||
|
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
|
||||||
|
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds));
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
|
||||||
|
documentId, versionId, currentUser.UserId, expirySeconds);
|
||||||
|
|
||||||
|
return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds)));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
|
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
|
||||||
/// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน)</summary>
|
/// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน)</summary>
|
||||||
private static void RevertStagedFieldChanges(
|
private static void RevertStagedFieldChanges(
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,6 @@ public record VersionListItem(
|
||||||
string CreatedByUserId,
|
string CreatedByUserId,
|
||||||
string ContentHash,
|
string ContentHash,
|
||||||
object? Fields);
|
object? Fields);
|
||||||
|
|
||||||
|
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
|
||||||
|
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
|
@ -69,6 +71,32 @@ builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => poli
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod()));
|
.AllowAnyMethod()));
|
||||||
|
|
||||||
|
// --- dev HTTPS -------------------------------------------------------------
|
||||||
|
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
|
||||||
|
// ทั้ง task pane เป็น https://localhost:3000 อยู่แล้ว — จึงต้องมี endpoint https ให้ API ด้วย
|
||||||
|
// ใช้ cert ชุดเดียวกับ Keycloak ใน docker-compose (office-addin-dev-certs — CA ถูก trust ในเครื่อง)
|
||||||
|
// เปิดเฉพาะตอน Environment=Development และเจอไฟล์ cert (ปิดผ่าน DevHttps:Enabled=false หรือรัน Production)
|
||||||
|
// หมายเหตุ: เมื่อ Listen ตรงนี้ URL จาก launchSettings/ASPNETCORE_URLS จะถูกแทนที่ — พอร์ตอ่านจาก config
|
||||||
|
var devHttpsEnabled = builder.Configuration.GetValue<bool?>("DevHttps:Enabled") ?? builder.Environment.IsDevelopment();
|
||||||
|
if (devHttpsEnabled)
|
||||||
|
{
|
||||||
|
var certDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".office-addin-dev-certs");
|
||||||
|
var certPath = builder.Configuration["DevHttps:CertificatePath"] ?? Path.Combine(certDirectory, "localhost.crt");
|
||||||
|
var keyPath = builder.Configuration["DevHttps:CertificateKeyPath"] ?? Path.Combine(certDirectory, "localhost.key");
|
||||||
|
if (File.Exists(certPath) && File.Exists(keyPath))
|
||||||
|
{
|
||||||
|
var certificate = LoadCertificateWithPrivateKey(certPath, keyPath);
|
||||||
|
var httpPort = builder.Configuration.GetValue("DevHttps:HttpPort", 5075);
|
||||||
|
var httpsPort = builder.Configuration.GetValue("DevHttps:HttpsPort", 7232);
|
||||||
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
|
{
|
||||||
|
options.ListenLocalhost(httpPort);
|
||||||
|
options.ListenLocalhost(httpsPort, listenOptions => listenOptions.UseHttps(certificate));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
|
|
@ -83,3 +111,15 @@ app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>โหลด cert PEM + private key เป็น X509Certificate2 พร้อมใช้กับ Kestrel
|
||||||
|
/// (key ของ office-addin-dev-certs เป็น PKCS#1 ซึ่ง overload UseHttps(path, path) ไม่รับ — ต้องโหลดเอง)</summary>
|
||||||
|
static X509Certificate2 LoadCertificateWithPrivateKey(string certPath, string keyPath)
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(File.ReadAllText(keyPath)); // รองรับทั้ง PKCS#1 และ PKCS#8
|
||||||
|
var leaf = X509Certificate2.CreateFromPem(File.ReadAllText(certPath));
|
||||||
|
using var paired = leaf.CopyWithPrivateKey(rsa);
|
||||||
|
// macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory
|
||||||
|
return new X509Certificate2(paired.Export(X509ContentType.Pfx));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,7 @@ public class MinioOptions
|
||||||
public string VersionsBucket { get; set; } = "docx-versions";
|
public string VersionsBucket { get; set; } = "docx-versions";
|
||||||
|
|
||||||
public string TemplatesBucket { get; set; } = "docx-templates";
|
public string TemplatesBucket { get; set; } = "docx-templates";
|
||||||
|
|
||||||
|
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
|
||||||
|
public int PresignedExpirySeconds { get; set; } = 120;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,23 @@ public interface IObjectStorageService
|
||||||
|
|
||||||
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
|
||||||
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
|
||||||
|
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
|
||||||
|
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
|
||||||
public class ObjectStorageService : IObjectStorageService
|
public class ObjectStorageService : IObjectStorageService
|
||||||
{
|
{
|
||||||
private readonly IAmazonS3 _s3;
|
private readonly IAmazonS3 _s3;
|
||||||
|
private readonly bool _useHttps;
|
||||||
private readonly ILogger<ObjectStorageService> _logger;
|
private readonly ILogger<ObjectStorageService> _logger;
|
||||||
|
|
||||||
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
|
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
|
||||||
{
|
{
|
||||||
var opt = options.Value;
|
var opt = options.Value;
|
||||||
|
_useHttps = opt.UseSSL;
|
||||||
var config = new AmazonS3Config
|
var config = new AmazonS3Config
|
||||||
{
|
{
|
||||||
ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
|
ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
|
||||||
|
|
@ -70,6 +76,27 @@ public class ObjectStorageService : IObjectStorageService
|
||||||
return new S3ResponseStream(response);
|
return new S3ResponseStream(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry)
|
||||||
|
{
|
||||||
|
var request = new GetPreSignedUrlRequest
|
||||||
|
{
|
||||||
|
BucketName = bucket,
|
||||||
|
Key = key,
|
||||||
|
Verb = HttpVerb.GET,
|
||||||
|
Expires = DateTime.UtcNow + expiry,
|
||||||
|
};
|
||||||
|
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
|
||||||
|
var url = _s3.GetPreSignedURL(request);
|
||||||
|
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้
|
||||||
|
// เพราะ SigV4 เซ็นค่า host header (host:port) ไม่รวม scheme → signature ยังถูกต้อง
|
||||||
|
if (!_useHttps && url.StartsWith("https://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
url = "http://" + url["https://".Length..];
|
||||||
|
}
|
||||||
|
_logger.LogInformation("Issued presigned GET {Bucket}/{Key} expiry={Expiry}s", bucket, key, expiry.TotalSeconds);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
|
||||||
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
|
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
|
||||||
private sealed class S3ResponseStream(GetObjectResponse response) : Stream
|
private sealed class S3ResponseStream(GetObjectResponse response) : Stream
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,12 @@
|
||||||
"MinIO": {
|
"MinIO": {
|
||||||
"AccessKey": "wordversioning-minio",
|
"AccessKey": "wordversioning-minio",
|
||||||
"SecretKey": "dev-local-pass"
|
"SecretKey": "dev-local-pass"
|
||||||
|
},
|
||||||
|
|
||||||
|
"DevHttps": {
|
||||||
|
"//": "endpoint https ของ dev (Word webview บล็อก http จากหน้า https) — cert จาก ~/.office-addin-dev-certs",
|
||||||
|
"Enabled": true,
|
||||||
|
"HttpPort": 5075,
|
||||||
|
"HttpsPort": 7232
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Authority": "http://localhost:8080/realms/word-versioning",
|
"Authority": "https://localhost:8443/realms/word-versioning",
|
||||||
"ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ]
|
"ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,16 @@ services:
|
||||||
environment:
|
environment:
|
||||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
|
||||||
|
# HTTPS ด้วย cert ของ office-addin-dev-certs (CA ถูก trust ใน keychain แล้ว)
|
||||||
|
# จำเป็นเพราะ Office Dialog API บังคับ HTTPS สำหรับหน้า login — http:// โดนปฏิเสธ
|
||||||
|
KC_HTTPS_CERTIFICATE_FILE: /certs/localhost.crt
|
||||||
|
KC_HTTPS_CERTIFICATE_KEY_FILE: /certs/localhost.key
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080" # admin console + OIDC endpoints
|
- "8443:8443" # OIDC endpoints + admin console (HTTPS — ใช้ตัวนี้)
|
||||||
|
# 8080 (http) เปิดไว้เฉพาะใน network ของ compose ให้ service `api` (profile) ใช้ภายใน
|
||||||
volumes:
|
volumes:
|
||||||
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||||
|
- ${HOME}/.office-addin-dev-certs:/certs:ro
|
||||||
|
|
||||||
# --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
|
# --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
|
||||||
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000
|
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||||
|
|
||||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
||||||
> สถานะ: Phase 0–4 เสร็จ (scaffold + infra + backend ครบ + add-in services layer) — ต่อไป: phase 5 (add-in UI) หรือ phase 6 (web) ทำขนานกันได้
|
> สถานะ: Phase 0–5 เสร็จ (backend ครบ + add-in ครบทั้ง services และ UI — เหลือตรวจ sideload บน Word จริง) — ต่อไป: phase 6 (web portal)
|
||||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -51,24 +51,32 @@
|
||||||
/word-addin
|
/word-addin
|
||||||
/addin # Phase 0, 4, 5
|
/addin # Phase 0, 4, 5
|
||||||
manifest.xml
|
manifest.xml
|
||||||
|
eslint.config.mjs # office-addin-lint ใช้ไฟล์นี้แทน config ของมัน (ปิด rule ที่ crash)
|
||||||
|
jest.config.js
|
||||||
/src
|
/src
|
||||||
/taskpane
|
/taskpane
|
||||||
taskpane.html
|
taskpane.html
|
||||||
taskpane.tsx
|
index.tsx # Office.onReady → FluentProvider + App
|
||||||
auth-callback.html
|
auth-callback.html/.ts # redirect target ของ Keycloak (รันใน Office Dialog)
|
||||||
/components
|
/components
|
||||||
|
App.tsx # state machine: checking → signed-out → untracked → tracking
|
||||||
VersionHistoryPanel.tsx
|
VersionHistoryPanel.tsx
|
||||||
ConfirmVersionDialog.tsx
|
ConfirmVersionDialog.tsx
|
||||||
/services
|
/services
|
||||||
|
authService.ts # PKCE + Dialog API + token in-memory + silent refresh
|
||||||
metadataService.ts # custom properties + custom XML part + content controls
|
metadataService.ts # custom properties + custom XML part + content controls
|
||||||
versioningService.ts
|
fileService.ts # getFileAsync → slices → Blob (normalize slice.data ทุกรูปแบบ)
|
||||||
fileService.ts
|
apiClient.ts # fetch + Bearer + retry-on-401 ครั้งเดียว
|
||||||
authService.ts
|
versioningService.ts # uploadVersion / getVersions / download + download-link
|
||||||
apiClient.ts
|
versionFlow.ts # orchestration ของ Start Versioning / Save & Version (7 ขั้น)
|
||||||
|
errors.ts
|
||||||
/models
|
/models
|
||||||
versionRecord.ts
|
versionRecord.ts
|
||||||
|
/test # wordMock / httpMock / setup (jsdom polyfills)
|
||||||
package.json
|
package.json
|
||||||
webpack.config.js
|
webpack.config.js # DefinePlugin: KEYCLOAK_AUTHORITY / API_BASE_URL จาก env
|
||||||
|
/scripts
|
||||||
|
dev-token.py # เดิน PKCE จริงได้ token ทดสอบ backend (admin-cli ไม่มี aud)
|
||||||
/backend # Phase 0, 2, 3
|
/backend # Phase 0, 2, 3
|
||||||
/WordVersioning.Api
|
/WordVersioning.Api
|
||||||
Program.cs
|
Program.cs
|
||||||
|
|
@ -344,13 +352,13 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
|
|
||||||
**เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)
|
**เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)
|
||||||
|
|
||||||
- [ ] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
|
- [x] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
|
||||||
- [ ] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
|
- [x] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
|
||||||
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
|
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
|
||||||
- ไม่ตรง → ปุ่ม **"Start Versioning This Document"**
|
- ไม่ตรง → ปุ่ม **"Start Versioning This Document"**
|
||||||
- [ ] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
|
- [x] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
|
||||||
- [ ] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
|
- [x] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
|
||||||
- [ ] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
|
- [x] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
|
||||||
1. เปิด confirm dialog
|
1. เปิด confirm dialog
|
||||||
2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
|
||||||
3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
|
3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
|
||||||
|
|
@ -359,13 +367,29 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย
|
6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย
|
||||||
7. แสดงผล + refresh history
|
7. แสดงผล + refresh history
|
||||||
- ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection**
|
- ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection**
|
||||||
- [ ] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`)
|
- [x] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`)
|
||||||
- [ ] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
|
- [x] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
|
||||||
- [ ] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
|
- [x] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
|
||||||
- [ ] Fluent UI components ให้หน้าตาเข้ากับ Office
|
- [x] Fluent UI components ให้หน้าตาเข้ากับ Office
|
||||||
|
|
||||||
|
หมายเหตุจากการทำจริง (2026-08-19):
|
||||||
|
- **state machine อยู่ที่ `src/taskpane/components/App.tsx`** (yo office ให้โครง index.tsx + App.tsx — ไม่มี taskpane.tsx ตามชื่อในแผน) และ orchestration ของ Start Versioning / Save & Version แยกเป็น `services/versionFlow.ts` เพื่อ unit test ได้ (mock ทั้ง Word/Office/fetch) — `npm test` ขยายเป็น 45 ตัว
|
||||||
|
- **`LastVersionId` ในไฟล์ = `clientVersionId`** (GUID ที่ client generate) ไม่ใช่ versionId ของ backend — เพราะต้อง stamp ลง properties *ก่อน* `document.save()` จึงจะไปอยู่ในไฟล์ที่อัปโหลด (backend versionId ได้มาหลังไฟล์ถูก capture ไปแล้ว) · ส่วน entry ใน XML part เดาเลข version จาก history ล่าสุด+1 ก่อน upload ถ้า backend ตอบเลขต่าง (มี client อื่นแทรก) จะเขียน XML part แก้ให้ตรงแบบ best-effort
|
||||||
|
- **เปิด `strict` ใน tsconfig ของ addin** — พบว่า tsconfig ของ scaffold ปิด strictNullChecks ซึ่งทำให้ boolean-literal discriminated union (`{ok:true} | {ok:false; error}`) **narrow ไม่ได้** (compile error) — เปิดแล้วมี error เพิ่มแค่ 4 จุดใน test (แก้แล้ว)
|
||||||
|
- ชื่อไฟล์ตอน upload ได้จาก `Office.context.document.url` (desktop มักมี path จริง) fallback `{documentId}.docx` (Word Online อาจไม่มี URL)
|
||||||
|
- FR-3: poll ทุก 30 วิ **เฉพาะ phase `tracking`** อ่าน `context.document.saved` — fail รอบใดข้ามรอบนั้น (banner เป็น soft reminder เท่านั้น)
|
||||||
|
- ดาวน์โหลด version: fetch เป็น blob (ต้องแนบ Bearer) → `URL.createObjectURL` + anchor `download` — ชื่อไฟล์ `document_v{N}.docx`
|
||||||
|
- ลบ scaffold demo ออก (Header/HeroList/TextInsertion/taskpane.ts) — task pane เป็นหน้าจริงของระบบแล้ว
|
||||||
|
- ⚠️ **DoD ส่วน sideload ต้องตรวจด้วยมือใน Word จริง** (Claude รัน Word ไม่ได้) — ขั้นตอน: รัน infra + backend + `npm start` แล้ว sideload manifest.xml บน Word Desktop/Online แล้วไล่ checklist AC-1, 2, 4–8, 13 (ส่วนที่ automate ได้คือ build/test/lint/validate ซึ่งผ่านทั้งหมดแล้ว)
|
||||||
|
- 🩹 **แก้จาก sideload test จริงรอบแรก:** กด Sign in แล้วโดน "The HTTP protocol is not supported. Use HTTPS instead" — **Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์** ทั้งที่ Keycloak dev เดิมรัน http://localhost:8080 → แก้โดยให้ Keycloak เสิร์ฟ **HTTPS ที่ 8443** ด้วย cert ของ `office-addin-dev-certs` (mount `${HOME}/.office-addin-dev-certs` เข้า container + `KC_HTTPS_CERTIFICATE_FILE/KEY_FILE`) · 8080 เหลือไว้เฉพาะภายใน network ของ compose (ให้ service `api` ใช้) · ค่าที่ตามไปเปลี่ยน: backend `Authentication:Authority` → `https://localhost:8443/realms/word-versioning`, add-in webpack default `KEYCLOAK_AUTHORITY` → เดียวกัน, README/.env.example/test constants ทั้งหมด · ตรวจจริงครบ: discovery ผ่าน (issuer = https…8443), PKCE flow จริงได้ token ที่มี `aud` ถูกต้อง, backend ตอบ 200 · **ผลพลอยได้:** เจอว่าตัวอย่าง curl admin-cli เดิมเรียก backend ไม่ผ่าน (token ไม่มี `aud`) → เพิ่ม `scripts/dev-token.py` (เดิน PKCE จริง ได้ token ใช้ทดสอบ backend/Swagger) และแก้ README ให้ใช้แทน
|
||||||
|
- 🩹 **แก้จาก sideload test จริงรอบสี่:** กดดาวน์โหลด version แล้ว**ไฟล์ไม่ไปไหนเลย** (ค้น ~/Downloads + Word container + Spotlight ไม่เจอ) — **WKWebView ของ Word ทิ้ง blob download (`a[download]` + object URL) แบบเงียบๆ** → เปลี่ยนวิธีเป็น "ขอ presigned URL อายุสั้นแล้วเปิดในเบราว์เซอร์ของเครื่อง": backend เพิ่ม `GET .../download-link` (MinIO presigned GET · อายุ config `MinIO:PresignedExpirySeconds` default 120s clamp 30–600 — spec ส่วน 9 อนุญาต presigned URL อายุสั้น) · add-in ใช้ `Office.context.ui.openBrowserWindow` (manifest เพิ่ม `OpenBrowserWindowApi 1.1`) โดยมี blob เดิมเป็น fallback บน host ที่ไม่มี API นี้ · จุดเจาะจง: **AWSSDK.S3 v4 บังคับ https ใน presigned URL เสมอ** (พิสูจน์ด้วยโปรเจกต์ทดสอบแยก — UseHttp ไม่ช่วย) แต่ SigV4 เซ็นค่า host header ไม่รวม scheme จึงสลับ https→http หลังเซ็นตาม config `UseSSL` ได้ · ตรวจจริง: upload → ขอ link → curl presigned URL ได้ไบต์ตรงเป๊ะ ✓ · อื่นๆ รอบนี้: **ลบ `manifest.json` ทิ้ง** (unified manifest ฝั่ง Outlook จาก scaffold — ไม่ได้ใช้ แก้แล้วไม่มีผลกับ Word) และเปลี่ยนชื่อเป็น **e-Court Documents Versioning** ทั้งใน manifest.xml (DisplayName/group/ปุ่ม) และหัว task pane · ⚠️ **อย่า `cp` ทับ manifest ใน wef folder** — office-addin-debugging สร้าง symlink ไว้ การ cp ทับทำให้กลายเป็นไฟล์ธรรมดา แล้ว `npm start` ครั้งถัดไปจะ EEXIST — ให้แก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word
|
||||||
|
- 🩹 **แก้จาก sideload test จริงรอบสาม:** เข้า state tracking ได้แล้ว แต่ทุก API call โดน "Load failed" — **WKWebView ของ Word บน Mac บล็อก fetch จากหน้า https ไป http แบบเงียบๆ (mixed content/ATS)**: task pane เป็น `https://localhost:3000` ส่วน API เดิมเป็น `http://localhost:5075` (login ผ่านเพราะ Keycloak เป็น https แล้ว · CORS ตรวจแล้วสมบูรณ์ ไม่ใช่ต้นเหตุ) → แก้ด้วยวิธีเดียวกับ Keycloak: **backend เปิด endpoint `https://localhost:7232` ด้วย dev cert ชุดเดียวกัน** — `Program.cs` เพิ่ม `ConfigureKestrel` (เปิดเฉพาะ Development + เจอ cert, config `DevHttps:*` ใน appsettings.Development.json, Listen ทั้ง 5075/7232 แทน URL จาก launchSettings) · จุดเจาะจง: key ของ office-addin-dev-certs เป็น **PKCS#1** ซึ่ง `UseHttps(cert, key)` overload ไม่รับ → โหลดเองด้วย `RSA.ImportFromPem` + `CopyWithPrivateKey` + export PFX in-memory (macOS ต้องทำแบบนี้ SslStream ถึงใช้ key ได้) · add-in `API_BASE_URL` default → `https://localhost:7232` · ตรวจจริง: รัน temp instance แล้ว curl ผ่าน https ด้วย dev-token.py ได้ 200 · **สรุปกติกา dev: ทุก origin ที่หน้า https จะเรียก (Keycloak 8443, backend 7232) ต้องเป็น https ด้วย cert จาก office-addin-dev-certs ทั้งหมด**
|
||||||
|
- 🩹 **แก้จาก sideload test จริงรอบสอง:** login ผ่านแล้ว กด Start Versioning โดน "รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)" — **host แต่ละตัวส่ง `slice.data` กลับคนละรูป** (เอกสารระบุลอยๆ ว่า "byte array"): Word Win = ArrayBuffer/view แต่ **Word Mac ส่งมาเป็นอย่างอื่น** (base64 string / number[]) → `sliceDataToBytes()` ใน fileService รองรับครบทั้ง 4 รูปแบบ + เติม test ครบทุกรูป (47 ตัว) · อีกเรื่องที่เจอ: restart `npm start` ไม่ได้แทน dev-server ตัวเก่า (port 3000 ถูกถือครอง → ตัวเก่ายัง serve bundle เก่า) — ต้อง kill ตัวเก่าก่อนเสมอ · และ webpack **ไม่ restart เองเมื่อ webpack.config.js เปลี่ยน** (DefinePlugin ค่าเก่าติดไปกับ bundle เก่า) — แก้ config แล้วต้อง restart dev-server ทุกครั้ง
|
||||||
|
|
||||||
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
|
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
|
||||||
|
|
||||||
|
> 🟡 implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · **ยังเหลือ**: ยืนยันการดาวน์โหลดผ่าน presigned link หลังแก้รอบล่าสุด, ไล่ AC เต็มชุด (โดยเฉพาะ AC-9/10/13) และทดสอบบน Word Online/Windows
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*
|
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*
|
||||||
|
|
|
||||||
114
scripts/dev-token.py
Executable file
114
scripts/dev-token.py
Executable file
|
|
@ -0,0 +1,114 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ขอ access token แบบ PKCE (authorization code flow) จาก Keycloak dev — สำหรับทดสอบ backend ผ่าน curl/Swagger.
|
||||||
|
|
||||||
|
ทำไมต้องมีสคริปต์นี้: ทุก client ใน realm ปิด direct grants บังคับ PKCE ตาม spec
|
||||||
|
ส่วน token ของ `admin-cli` ไม่มี claim `aud` จึงเรียก backend ไม่ผ่าน (401)
|
||||||
|
|
||||||
|
วิธีใช้:
|
||||||
|
python3 scripts/dev-token.py # ปริ้น access token
|
||||||
|
python3 scripts/dev-token.py --clip # คัดลอกเข้า clipboard (macOS)
|
||||||
|
|
||||||
|
แก้ user/password ผ่าน env: DEV_TOKEN_USERNAME / DEV_TOKEN_PASSWORD (default testuser/Test1234!)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
REALM = "https://localhost:8443/realms/word-versioning"
|
||||||
|
CLIENT_ID = "word-versioning-web"
|
||||||
|
REDIRECT_URI = "http://localhost:5173/callback" # ไม่ต้องมี server จริง — แค่ต้องตรงกับที่ลงทะเบียนไว้
|
||||||
|
CA_FILE = os.path.expanduser("~/.office-addin-dev-certs/ca.crt")
|
||||||
|
|
||||||
|
USERNAME = os.environ.get("DEV_TOKEN_USERNAME", "testuser")
|
||||||
|
PASSWORD = os.environ.get("DEV_TOKEN_PASSWORD", "Test1234!")
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""หยุดที่ 302 เพื่อเก็บ authorization code จาก Location"""
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--clip", action="store_true", help="คัดลอก token เข้า clipboard (pbcopy)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
verifier = base64.urlsafe_b64encode(os.urandom(48)).rstrip(b"=").decode()
|
||||||
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(cafile=CA_FILE)
|
||||||
|
opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
|
||||||
|
NoRedirect,
|
||||||
|
urllib.request.HTTPSHandler(context=ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_url = f"{REALM}/protocol/openid-connect/auth?" + urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": "openid",
|
||||||
|
"state": "dev-token",
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
login_page = opener.open(auth_url).read().decode()
|
||||||
|
action = re.search(r'action="([^"]+)"', login_page)
|
||||||
|
if not action:
|
||||||
|
print("ไม่พบ login form — ตรวจ Keycloak ที่ https://localhost:8443", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
credentials = urllib.parse.urlencode(
|
||||||
|
{"username": USERNAME, "password": PASSWORD, "credentialId": ""}
|
||||||
|
).encode()
|
||||||
|
try:
|
||||||
|
opener.open(urllib.request.Request(action.group(1).replace("&", "&"), data=credentials))
|
||||||
|
print("login ไม่ redirect — ตรวจ username/password", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
location = error.headers.get("Location", "")
|
||||||
|
code = urllib.parse.parse_qs(urllib.parse.urlparse(location).query).get("code", [None])[0]
|
||||||
|
if not code:
|
||||||
|
print("ไม่ได้รับ authorization code (redirect ผิดหรือ login พลาด)", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
token_body = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
token_response = json.loads(
|
||||||
|
opener.open(urllib.request.Request(f"{REALM}/protocol/openid-connect/token", data=token_body)).read()
|
||||||
|
)
|
||||||
|
token = token_response["access_token"]
|
||||||
|
|
||||||
|
if args.clip:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.run("pbcopy", input=token.encode(), check=True)
|
||||||
|
print(f"คัดลอก access token ({len(token)} chars) เข้า clipboard แล้ว")
|
||||||
|
else:
|
||||||
|
print(token)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
||||||
|
|
||||||
|
> ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link
|
||||||
> ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md`
|
> ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md`
|
||||||
|
|
||||||
## 1. ภาพรวม
|
## 1. ภาพรวม
|
||||||
|
|
@ -31,6 +32,13 @@
|
||||||
- **Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า** → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่
|
- **Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า** → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่
|
||||||
- Manifest ต้อง support ทั้ง requirement set ที่ `document.customXmlParts`, `document.properties.customProperties`, และ `document.save()` (WordApi 1.5+) ใช้งานได้
|
- Manifest ต้อง support ทั้ง requirement set ที่ `document.customXmlParts`, `document.properties.customProperties`, และ `document.save()` (WordApi 1.5+) ใช้งานได้
|
||||||
|
|
||||||
|
ข้อจำกัดเพิ่มเติมจากการทดสอบจริงบน Word for Mac (v3):
|
||||||
|
|
||||||
|
- **Office Dialog API ปฏิเสธ URL ที่เป็น http:// โดยสมบูรณ์** (error "The HTTP protocol is not supported. Use HTTPS instead") → หน้า login ของ IdP ต้องเสิร์ฟ HTTPS เสมอแม้ตอน dev
|
||||||
|
- **Webview ของ host (โดยเฉพาะ WKWebView ใน Word for Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ** (mixed content — error "Load failed", request ไม่เคยถึง server, CORS ไม่เกี่ยว) → ทุก service ที่ task pane (หน้า https) จะเรียกต้องเป็น HTTPS ทั้งหมด
|
||||||
|
- **ข้อมูล slice จาก `getSliceAsync` มีได้หลายรูปแบบตาม host/แพลตฟอร์ม** — ArrayBuffer, typed-array view, base64 string, หรือ `number[]` ธรรมดา → ต้อง normalize ครบทุกกรณีก่อนประกอบเป็นไฟล์
|
||||||
|
- **Webview ของ host ทิ้ง blob download (`a[download]` + object URL) แบบเงียบๆ** — ไฟล์ไม่ถูกเขียนที่ไหนเลย → การดาวน์โหลดไฟล์จาก add-in ต้องขอ presigned URL อายุสั้นจาก backend แล้วเปิดในเบราว์เซอร์ของเครื่อง (`Office.context.ui.openBrowserWindow`) แทน
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. เทคโนโลยี
|
## 3. เทคโนโลยี
|
||||||
|
|
@ -166,9 +174,11 @@ GET /api/documents/{documentId}/versions
|
||||||
|
|
||||||
### FR-7: ดาวน์โหลด version ที่ระบุ
|
### FR-7: ดาวน์โหลด version ที่ระบุ
|
||||||
```
|
```
|
||||||
GET /api/documents/{documentId}/versions/{versionId}/download
|
GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger)
|
||||||
|
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้)
|
||||||
```
|
```
|
||||||
- Stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง (ใช้ presigned URL หรือ proxy stream ผ่าน backend ก็ได้ — แนะนำ proxy stream เพื่อคุม auth ที่ endpoint เดียว ไม่ต้อง expose MinIO ตรงๆ ให้ client)
|
- `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง
|
||||||
|
- `download-link` — ออก **presigned URL อายุสั้น** (config ได้, default ~2 นาที, clamp 30 วิ–10 นาที) ให้ client เปิดในเบราว์เซอร์ของเครื่อง — เป็น**กลไกหลักของ add-in** เพราะ webview ของ host ทิ้ง blob download แบบเงียบๆ (v3) · เข้าถึง MinIO โดยตรงโดยไม่ต้องแนบ token แต่หมดอายุทันทีเมื่อเกินเวลา
|
||||||
|
|
||||||
### FR-8: Auth (Keycloak / OpenID Connect)
|
### FR-8: Auth (Keycloak / OpenID Connect)
|
||||||
|
|
||||||
|
|
@ -302,7 +312,7 @@ Custom Document Properties:
|
||||||
|---|---|
|
|---|---|
|
||||||
| `SavedByAddIn` | GUID คงที่ของ add-in — ค่าที่ปักหมุด: `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D` (backend: `Services/AddInMetadata.AddInSignatureGuid`, add-in: `ADDIN_SIGNATURE_GUID` ต้องเป็นค่านี้เป๊ะ) |
|
| `SavedByAddIn` | GUID คงที่ของ add-in — ค่าที่ปักหมุด: `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D` (backend: `Services/AddInMetadata.AddInSignatureGuid`, add-in: `ADDIN_SIGNATURE_GUID` ต้องเป็นค่านี้เป๊ะ) |
|
||||||
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
|
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
|
||||||
| `LastVersionId` | GUID ของ version ล่าสุด |
|
| `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) |
|
||||||
| `LastVersionTimestamp` | ISO-8601 UTC timestamp |
|
| `LastVersionTimestamp` | ISO-8601 UTC timestamp |
|
||||||
|
|
||||||
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
|
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
|
||||||
|
|
@ -333,6 +343,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
|
||||||
- OpenXML replace ต้องแก้ที่ **run level ใน SDT** เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph
|
- OpenXML replace ต้องแก้ที่ **run level ใน SDT** เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph
|
||||||
- Web Portal ทุกหน้ารองรับ **light/dark theme** (toggle + persist + default ตาม `prefers-color-scheme`)
|
- Web Portal ทุกหน้ารองรับ **light/dark theme** (toggle + persist + default ตาม `prefers-color-scheme`)
|
||||||
- **Dev server ports ห้ามชนกัน:** add-in = `https://localhost:3000`, Web Portal = `http://localhost:5173` (ตั้งใน `nuxt.config.ts`)
|
- **Dev server ports ห้ามชนกัน:** add-in = `https://localhost:3000`, Web Portal = `http://localhost:5173` (ตั้งใน `nuxt.config.ts`)
|
||||||
|
- **Dev ต้องเสิร์ฟ HTTPS ด้วย cert ของ `office-addin-dev-certs` ทุก service ที่ task pane เรียก** (v3): Keycloak = `https://localhost:8443` (dialog บังคับ HTTPS), backend = `https://localhost:7232` เพิ่มคู่กับ `http://localhost:5075` (กัน mixed content) — ต้องรัน `npx office-addin-dev-certs install` **ก่อน** `docker compose up` เพราะ Keycloak mount cert จาก `~/.office-addin-dev-certs`
|
||||||
- Backend CORS ต้องอนุญาตทั้ง origin ของ add-in (`https://localhost:3000`) และ Web Portal (`http://localhost:5173`)
|
- Backend CORS ต้องอนุญาตทั้ง origin ของ add-in (`https://localhost:3000`) และ Web Portal (`http://localhost:5173`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue