Refactor Word Add-in authentication to use e-Court pass-through; remove Keycloak integration

- Updated documentation to reflect removal of login and transition to e-Court pass-through authentication.
- Modified `authService` to handle authentication via custom properties in documents without user login.
- Adjusted `apiClient` to immediately throw `AuthRequiredError` for invalid tokens, as refresh tokens are no longer applicable.
- Revised UI flow to accommodate new authentication method, including changes to state management and error handling.
- Added new requirements document outlining the add-in workflow and authentication process.
This commit is contained in:
Suphonchai Phoonsawat 2026-10-27 09:44:28 +07:00
parent 6ce071ee14
commit 50af6c10fa
9 changed files with 161 additions and 197 deletions

View file

@ -2,17 +2,17 @@ import { config } from "../config";
import * as authService from "./authService";
/**
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts
* fetch wrapper ของ add-in — แนบ Bearer ทุก request (token จาก custom properties ของไฟล์)
* โดน 401 → AuthRequiredError ทันที: token จากไฟล์ต่ออายุไม่ได้ (ไม่มี refresh token
* ตั้งแต่ยกเลิก Keycloak) — UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
* พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
* ถูก TaskpaneApp ใช้อยู่แล้ว)
*/
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
/** บอกว่า token จากไฟล์ใช้ไม่ได้ (ไม่มี / หมดอายุ / ไม่ถูกต้อง) — ต้องได้ไฟล์ใหม่จาก e-Court */
export class AuthRequiredError extends Error {
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
constructor(message = "token ของ e-Court หมดอายุหรือไม่ถูกต้อง") {
super(message);
this.name = "AuthRequiredError";
}
@ -54,25 +54,17 @@ async function doFetch(
}
/**
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
* request พร้อม Bearer — 401 = token จากไฟล์หมดอายุ/ไม่ถูกต้อง → AuthRequiredError
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง
*/
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
const accessToken = await authService.getAccessToken();
if (!accessToken) {
throw new AuthRequiredError();
}
let response = await doFetch(path, options, accessToken);
const response = await doFetch(path, options, accessToken);
if (response.status === 401) {
const refreshedToken = await authService.refreshAccessToken();
if (!refreshedToken) {
throw new AuthRequiredError();
}
response = await doFetch(path, options, refreshedToken);
if (response.status === 401) {
throw new AuthRequiredError();
}
throw new AuthRequiredError();
}
return response;
}

View file

@ -1,34 +1,21 @@
/**
* MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต)
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API,
* token เก็บใน memory เท่านั้น, auto-refresh)
* Auth ของ add-in — e-Court pass-through ทางเดียว (ยกเลิก Keycloak 2026-10-27 ตามคำขอผู้ใช้)
* ไม่มีขั้นตอน login ใน add-in อีกต่อไป: ไฟล์ที่ e-Court stamp custom properties
* `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = ใช้งานได้ทันที ไม่ต้องกดปุ่มใด ๆ
* ไฟล์ที่ไม่มีทั้งคู่ = phase no-access ของ TaskpaneApp (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต)
*
* วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว
* (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ
* token เก็บใน memory เท่านั้น — getAccessToken คืน docToken ให้ apiClient แนบ Bearer
* (ช่วงไม่ต่อ backend จริง versioningService เป็น mock ไม่เรียก apiClient อยู่แล้ว)
*/
import { config } from "../config";
/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */
export type LoginResult = { ok: true } | { ok: false; error: string };
/** state เดียวของ mock — null = signed out */
/** state เดียว — null = ยังไม่เคย pass-through สำเร็จ (จากไฟล์ หรือ ?addindemo) */
let username: string | null = null;
/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */
let docToken: string | null = null;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */
export async function login(): Promise<LoginResult> {
await delay(300);
username = config.mockUsername;
return { ok: true };
}
/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */
/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */
export function signInForDemo(): void {
username = config.mockUsername;
}
@ -50,8 +37,8 @@ function decodeJwtField(token: string, field: string): string | undefined {
}
/**
* e-Court pass-through (ระยะแรก) — ไฟล์ที่ e-Court stamp `token`/`owner` ไว้ใน custom properties
* ใช้งานได้เลยโดยไม่ต้องกด login: username จาก owner → uid ใน token → mockUsername
* e-Court pass-through — จุดเข้าเดียวของ auth: ไฟล์ที่ stamp `token`/`owner` ไว้
* username ได้จาก owner → uid ใน JWT → mockUsername (ตามลำดับ)
* คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว)
*/
export function signInFromDocument(input: { token?: string; owner?: string }): boolean {
@ -65,33 +52,12 @@ export function signInFromDocument(input: { token?: string; owner?: string }): b
return true;
}
/** Bearer ของ apiClient — docToken จากไฟล์ (ส่วน demo/mock คืน token ปลอม) */
export async function getAccessToken(): Promise<string | null> {
if (docToken) return docToken;
return username ? `mock-token-${username}` : null;
}
/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */
export async function refreshAccessToken(): Promise<string | null> {
return getAccessToken();
}
export function getUsername(): string | undefined {
return username ?? undefined;
}
export function isTokenValid(): boolean {
return username !== null;
}
export function hasRefreshToken(): boolean {
return username !== null;
}
export function startAutoRefresh(): void {
// mock: ไม่มีอะไรต้อง refresh
}
export function signOut(): void {
username = null;
docToken = null;
}

View file

@ -23,7 +23,7 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
export const TEMPLATE_ID_PROPERTY = "TemplateId";
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลยไม่ต้องกด login */
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลย (auth ทางเข้าเดียว) */
export const ECOURT_TOKEN_PROPERTY = "token";
export const ECOURT_OWNER_PROPERTY = "owner";
@ -35,7 +35,7 @@ export interface TrackedDocumentProperties {
lastVersionTimestamp?: string;
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
templateId?: string;
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ข้ามหน้า login (username จาก owner หรือ uid ใน token) */
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ใช้งานได้ทันที (username จาก owner หรือ uid ใน token) */
ecourtToken?: string;
ecourtOwner?: string;
}