phase 6 เบื้องต้น

This commit is contained in:
Suphonchai Phoonsawat 2026-08-20 06:20:16 +07:00
parent 1bb850c6b8
commit 93570d38d8
20 changed files with 1715 additions and 34 deletions

View file

@ -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`)
**สถานะปัจจุบัน: Phase 0–5 เสร็จ** (backend ครบ + add-in ครบทั้ง services และ task pane UI — sideload ทดสอบจริงบน Word for Mac แล้ว แก้ปัญหาตกมุม 4 รอบ) — งานต่อคือ phase 6 (web portal), 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน
**สถานะปัจจุบัน: Phase 0–6 เสร็จ** (backend + add-in + web portal ครบ — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — งานต่อคือ phase 7 (tests ครบชุด), 8 (README + ไล่ acceptance criteria) ตามแผน
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: Phase 0–5 เสร็จ (backend ครบ + add-in ครบทั้ง services และ UI — เหลือตรวจ sideload บน Word จริง) — ต่อไป: phase 6 (web portal)
> สถานะ: Phase 0–6 เสร็จ (backend + add-in + web portal — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — ต่อไป: phase 7 (tests ครบชุด), 8 (README + AC)
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
---
@ -397,22 +397,34 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** เว็บ Nuxt + Tailwind สำหรับจัดการ template และสร้างเอกสาร ทุกหน้ารองรับ light/dark theme
### 6.1 พื้นฐาน
- [ ] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
- [ ] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
- [ ] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>`), persist ใน localStorage, default ตาม `prefers-color-scheme`
- [ ] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
- [x] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
- [x] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
- [x] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>`), persist ใน localStorage, default ตาม `prefers-color-scheme`
- [x] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
### 6.2 จัดการ template (FR-9 UI)
- [ ] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
- [ ] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
- [x] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
- [x] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
### 6.3 สร้างเอกสาร (FR-10 UI)
- [ ] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
- [ ] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
- [ ] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
- [x] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
- [x] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
- [x] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
หมายเหตุจากการทำจริง (2026-08-20):
- โครงไฟล์ตาม Nuxt 4 (dir `app/`): `app/composables`, `app/utils`, `app/components`, `app/plugins` auto-import — **import ข้ามโฟลเดอร์ต้องใช้ alias `~/utils/...`** (relative `./oidc` จาก composables ไม่ resolve — เจอตอน build แรก)
- **นโยบาย session ของเว็บ (กติกา #4 + แผนข้อ 5):** access/refresh/id token เก็บใน memory เท่านั้น; สิ่งที่ persist มีแค่ refresh token + id token (ไว้ restore หลัง reload และ logout แบบมี `id_token_hint`) ใน sessionStorage key `wv-session` + PKCE pending (`wv-oidc-pending`) ต้องข้าม navigation จึงจำเป็น — sessionStorage อายุสั้นตามที่แผนอนุญาต
- restore session ทำใน `app/plugins/auth.client.ts` (**async plugin** → รอผลก่อน render หน้าแรก กัน UI กระพริบไปหน้า login); สถานะ login ใน header ครอบ `<ClientOnly>` กัน hydration mismatch; ทุกหน้าครอบเนื้อหาด้วย `AuthGate` (ยังไม่ login → ปุ่มเข้าระบบ)
- `useApi`: Bearer ทุก request, 401 → force refresh → retry ครั้งเดียว; refresh พัง → เคลียร์ session แล้ว AuthGate สลับไปหน้า login เอง (พฤติกรรมเดียวกับ AC-9 ของ add-in) — และ `filenameFrom()` อ่านชื่อไฟล์จาก `Content-Disposition` รองรับ `filename*=UTF-8` ของ .NET (ชื่อไทย)
- ตรวจอัตโนมัติผ่านหมด (2026-08-20): `npm run build` exit 0 · SSR ทุก route (/, /templates, /templates/new, /templates/{id}/edit, /auth/callback ±query, /documents/create/{id}) ตอบ 200 พร้อมข้อความถูกต้อง · **เดิน PKCE จริงด้วยพารามิเตอร์เหมือน `useAuth` ทุกตัว** (redirect `/auth/callback`, scope=openid, S256) → Keycloak คืน code+state ตรง → แลก token ได้ `aud=word-versioning-web` + refresh/id token → ยิง `GET /api/templates` 200 → CORS preflight จาก 5173 ได้ 204 + allow-origin ถูก → refresh_token grant ผ่าน (path ที่ restoreSession ใช้) · **จำลอง request ทั้งลำดับที่หน้าเว็บส่ง** (multipart สร้าง template → PUT field defs → generate → download) → docx ค่าครบ + `SavedByAddIn`/`DocumentTrackingId` stamp ถูก
- 🩹 **รัน backend เจอ 500 "No password has been provided"**: `dotnet run --no-launch-profile` ตัด `ASPNETCORE_ENVIRONMENT=Development` (ปกติตั้งโดย launchSettings) ทิ้ง → ไปอ่าน appsettings.json ที่ DB password ว่าง — ต้องใส่ env นี้เองทุกครั้งที่ใช้ `--no-launch-profile`
- ดาวน์โหลดในเว็บใช้ blob + `a[download]` ได้ตามปกติ (browser จริง — ต่างจาก webview ของ Word ที่ต้องใช้ presigned link ตามกติกา #12)
- ⚠️ **เหลือตรวจด้วยมือ (ทำแทนไม่ได้):** เปิด browser จริง → login ผ่าน Keycloak → สร้าง/แก้ template → กรอกฟอร์มสร้างเอกสาร → ดาวน์โหลด → เปิดใน Word แล้ว add-in เห็น v1 ทันที (AC-11/12 เต็มรูปแบบ) + theme toggle จำค่าหลัง reload — ใน dev DB มี template "สัญญาทดสอบเว็บ" (เกิดจากสคริปต์ตรวจ) ไว้ click-through ได้ทันที และลบผ่านหน้าเว็บได้เอง
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · **ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ต้องยืนยันด้วยมือ** ร่วมกับ AC checklist ที่เหลือใน Phase 8
---
## Phase 7: Tests

9
web/.env.example Normal file
View file

@ -0,0 +1,9 @@
# ค่า default ของ local dev อยู่ใน nuxt.config.ts (runtimeConfig.public) — ไฟล์นี้ใช้ override ผ่าน env
# คัดลอกเป็น .env แล้วแก้ได้เลย (Nuxt อ่าน NUXT_PUBLIC_* อัตโนมัติ)
# backend API (ไม่มี slash ท้าย)
NUXT_PUBLIC_API_BASE_URL=http://localhost:5075
# Keycloak realm (ผ่าน https:8443 ด้วย cert ของ office-addin-dev-certs)
NUXT_PUBLIC_KEYCLOAK_AUTHORITY=https://localhost:8443/realms/word-versioning
NUXT_PUBLIC_KEYCLOAK_CLIENT_ID=word-versioning-web

View file

@ -1,22 +1,73 @@
<script setup lang="ts">
const { initTheme, toggle, isDark } = useTheme()
const { ready, isAuthenticated, username, login, logout } = useAuth()
onMounted(() => initTheme())
</script>
<template>
<div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
<header class="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<NuxtLink to="/" class="text-lg font-semibold">Word Versioning</NuxtLink>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="toggle"
>
{{ isDark ? '☀️ Light' : '🌙 Dark' }}
</button>
<header class="border-b border-slate-200 dark:border-slate-700">
<div class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 px-6 py-4">
<div class="flex items-baseline gap-3">
<NuxtLink to="/" class="text-lg font-semibold">e-Court Documents Versioning</NuxtLink>
<span class="hidden text-xs text-slate-500 dark:text-slate-400 sm:inline">web portal</span>
</div>
<nav class="flex items-center gap-2 text-sm">
<NuxtLink
to="/"
class="rounded-md px-2.5 py-1.5 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
สร้างเอกสาร
</NuxtLink>
<NuxtLink
to="/templates"
class="rounded-md px-2.5 py-1.5 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
จัดการ template
</NuxtLink>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="toggle"
>
{{ isDark ? '☀️ Light' : '🌙 Dark' }}
</button>
<!-- สถานะ login รู้ผลหลัง restore session (client) — จึงต้อง ClientOnly กัน hydration mismatch -->
<ClientOnly>
<template #fallback>
<span class="inline-block w-24 text-right text-xs text-slate-400">…</span>
</template>
<template v-if="ready">
<span v-if="isAuthenticated" class="max-w-40 truncate text-xs text-slate-500 dark:text-slate-400">
{{ username }}
</span>
<button
v-if="isAuthenticated"
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="logout"
>
ออกจากระบบ
</button>
<button
v-else
type="button"
class="rounded-md bg-sky-600 px-3 py-1.5 font-medium text-white hover:bg-sky-700"
@click="login()"
>
เข้าสู่ระบบ
</button>
</template>
</ClientOnly>
</nav>
</div>
</header>
<main class="mx-auto max-w-3xl p-6">
<main class="mx-auto max-w-5xl p-6">
<NuxtPage />
</main>
</div>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
/** กล่องข้อความ (error/success/info) ใช้ร่วมทุกหน้า */
const props = withDefaults(
defineProps<{
type?: 'error' | 'success' | 'info'
}>(),
{ type: 'info' },
)
const styles = computed(() => {
switch (props.type) {
case 'error':
return 'border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300'
case 'success':
return 'border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
default:
return 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300'
}
})
const icon = computed(() => (props.type === 'error' ? '⚠️' : props.type === 'success' ? '✅' : 'ℹ️'))
</script>
<template>
<div role="alert" class="rounded-md border px-4 py-3 text-sm" :class="styles">
<span class="mr-2" aria-hidden="true">{{ icon }}</span>
<slot />
</div>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
/** ครอบเนื้อหาหน้าที่ต้อง login — ยังไม่พร้อมแสดง spinner, ยังไม่ login แสดงปุ่มเข้าระบบ */
const { ready, isAuthenticated, login } = useAuth()
</script>
<template>
<div v-if="!ready" class="flex items-center justify-center gap-3 py-16 text-sm text-slate-500 dark:text-slate-400">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300" />
กำลังตรวจสอบสถานะการเข้าสู่ระบบ…
</div>
<div
v-else-if="!isAuthenticated"
class="rounded-lg border border-slate-200 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-800"
>
<h2 class="text-lg font-semibold">ต้องเข้าสู่ระบบก่อนใช้งานหน้านี้</h2>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
ระบบยืนยันตัวตนผ่าน Keycloak (realm <span class="font-mono">word-versioning</span>)
— กดปุ่มด้านล่างเพื่อเปิดหน้าเข้าสู่ระบบ
</p>
<button
type="button"
class="mt-4 rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
@click="login()"
>
เข้าสู่ระบบด้วย Keycloak
</button>
</div>
<slot v-else />
</template>

View file

@ -0,0 +1,108 @@
/**
* fetch wrapper ของ web portal — แนบ Bearer ทุก request,
* โดน 401 → force refresh token แล้ว retry ครั้งเดียว (ยัง 401/refresh พัง → throw ให้ UI กลับไปหน้า login)
*/
import { AuthError } from './useAuth'
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
}
}
/** ดึงข้อความ error จาก response ของ backend (string ตรงๆ หรือ ProblemDetails) */
async function toApiError(res: Response): Promise<ApiError> {
const fallback = `เรียก API ไม่สำเร็จ (HTTP ${res.status})`
try {
const text = (await res.text()).trim()
if (!text) return new ApiError(res.status, fallback)
try {
const json = JSON.parse(text) as { title?: string; detail?: string }
const msg = json.detail ?? json.title
return new ApiError(res.status, msg ?? text)
} catch {
return new ApiError(res.status, text) // backend คืน message เป็น string เปล่าๆ (เช่น BadRequest)
}
} catch {
return new ApiError(res.status, fallback)
}
}
export function useApi() {
const config = useRuntimeConfig()
const base = (config.public.apiBaseUrl as string).replace(/\/+$/, '')
const { getAccessToken } = useAuth()
async function request(path: string, init: RequestInit = {}): Promise<Response> {
const doFetch = async (forceRefresh: boolean): Promise<Response> => {
let token: string
try {
token = await getAccessToken(forceRefresh)
} catch (err) {
// refresh token ตาย — AuthError พร้อมข้อความชี้นำให้ login ใหม่ (สถานะ auth ถูกเคลียร์ไปแล้ว)
throw err instanceof AuthError ? new ApiError(401, err.message) : err
}
return fetch(base + path, {
...init,
headers: { ...(init.headers as Record<string, string> | undefined), Authorization: `Bearer ${token}` },
})
}
let res = await doFetch(false)
if (res.status === 401) res = await doFetch(true) // retry ครั้งเดียว
return res
}
/** เรียก API ที่คืน JSON (หรือ 204 → undefined) — !ok จะ throw ApiError */
async function apiJson<T>(path: string, init: RequestInit = {}): Promise<T | undefined> {
const res = await request(path, init)
if (!res.ok) throw await toApiError(res)
if (res.status === 204) return undefined
return (await res.json()) as T
}
/** upload multipart — ห้าม set Content-Type เอง (browser ต้องใส่ boundary ให้) */
async function apiUpload<T>(path: string, form: FormData, method: 'POST' | 'PUT' = 'POST'): Promise<T> {
return (await apiJson<T>(path, { method, body: form })) as T
}
/**
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
*/
async function downloadFile(path: string, fallbackName: string): Promise<void> {
const res = await request(path)
if (!res.ok) throw await toApiError(res)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filenameFrom(res.headers.get('Content-Disposition'), fallbackName)
document.body.append(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
return { request, apiJson, apiUpload, downloadFile }
}
/** เอาชื่อไฟล์จาก Content-Disposition (รองรับ filename*=UTF-8 ที่ .NET ใส่มาสำหรับชื่อไทย) */
function filenameFrom(header: string | null, fallback: string): string {
if (!header) return fallback
const utf8 = header.match(/filename\*=(?:UTF-8'')?([^;]+)/i)
if (utf8) {
try {
return decodeURIComponent(utf8[1].replace(/^"|"$/g, ''))
} catch {
/* ผ่านต่อ */
}
}
const plain = header.match(/filename=(?:"([^"]+)"|([^;]+))/i)
if (plain) return (plain[1] ?? plain[2]).trim()
return fallback
}

View file

@ -0,0 +1,257 @@
/**
* FR-8 ฝั่งเว็บ — OIDC authorization code + PKCE S256 ด้วย browser redirect ปกติ
* (เว็บเป็น top-level page จึงไม่ต้องใช้ Office Dialog API แบบ add-in)
*
* การเก็บค่าตามกติกา:
* - access/refresh/id token เก็บใน memory ของแท็บ (useState) เท่านั้น — ห้าม localStorage
* - สิ่งที่ต้องข้าม navigation: code_verifier + state (ตอนรอ redirect กลับ) และ refresh token
* (ไว้ restore session หลัง reload) — ใช้ sessionStorage อายุสั้นตามที่แผนอนุญาต
*/
import { buildAuthorizeUrl, createCodeChallenge, generateCodeVerifier, generateState, parseJwtPayload } from '~/utils/oidc'
import type { TokenResponse } from '~/utils/oidc'
import type { LocationQuery } from 'vue-router'
const PENDING_KEY = 'wv-oidc-pending'
const SESSION_KEY = 'wv-session'
interface PendingLogin {
verifier: string
state: string
returnTo: string
}
/** ส่วนที่ persist ใน sessionStorage — ไม่มี access token */
interface StoredSession {
refreshToken: string
idToken?: string
username?: string
}
interface MemoryTokens {
accessToken: string
refreshToken: string
idToken?: string
/** epoch ms ที่ token หมดอายุ */
expiresAt: number
}
interface AuthState {
tokens: MemoryTokens | null
username: string | null
/** false ระหว่าง restore session ตอน boot — กัน UI กระพริบไปหน้า login ก่อนรู้ผล */
ready: boolean
}
export interface CallbackResult {
ok: boolean
returnTo?: string
error?: string
}
export class AuthError extends Error {}
// dedupe concurrent refresh (หลาย request เจอ 401 พร้อมกัน) — ใช้เฉพาะฝั่ง client
let refreshInFlight: Promise<MemoryTokens> | null = null
export function useAuth() {
const config = useRuntimeConfig()
const authority = config.public.keycloakAuthority as string
const clientId = config.public.keycloakClientId as string
const state = useState<AuthState>('auth', () => ({ tokens: null, username: null, ready: false }))
const isAuthenticated = computed(() => state.value.tokens !== null)
function readPending(): PendingLogin | null {
try {
const raw = sessionStorage.getItem(PENDING_KEY)
return raw ? (JSON.parse(raw) as PendingLogin) : null
} catch {
return null
}
}
function writeSession(session: StoredSession | null) {
if (session === null) sessionStorage.removeItem(SESSION_KEY)
else sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
function readSession(): StoredSession | null {
try {
const raw = sessionStorage.getItem(SESSION_KEY)
return raw ? (JSON.parse(raw) as StoredSession) : null
} catch {
return null
}
}
/** บันทึก token ลง memory + persist เฉพาะ refresh/id token ลง sessionStorage */
function setTokens(res: TokenResponse) {
const claims = parseJwtPayload(res.access_token)
const expiresAt = res.expires_in
? Date.now() + res.expires_in * 1000
: typeof claims?.exp === 'number'
? claims.exp * 1000
: Date.now() + 60_000
const username =
(typeof claims?.preferred_username === 'string' && claims.preferred_username) ||
(typeof claims?.sub === 'string' && claims.sub) ||
state.value.username ||
readSession()?.username ||
''
const tokens: MemoryTokens = {
accessToken: res.access_token,
refreshToken: res.refresh_token ?? state.value.tokens?.refreshToken ?? readSession()?.refreshToken ?? '',
idToken: res.id_token ?? state.value.tokens?.idToken ?? readSession()?.idToken,
expiresAt,
}
if (!tokens.refreshToken) throw new AuthError('Keycloak ไม่ได้ส่ง refresh_token กลับมา')
state.value.tokens = tokens
state.value.username = username || null
writeSession({ refreshToken: tokens.refreshToken, idToken: tokens.idToken, username: state.value.username })
}
/** เข้าสู่ระบบ — สร้าง PKCE pair แล้ว redirect ไป Keycloak (เต็มหน้า) */
async function login(returnTo?: string) {
if (!import.meta.client) return
const verifier = generateCodeVerifier()
const pending: PendingLogin = {
verifier,
state: generateState(),
returnTo: returnTo ?? new URL(window.location.href).pathname,
}
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
const challenge = await createCodeChallenge(verifier)
const url = buildAuthorizeUrl({
authorizeEndpoint: `${authority}/protocol/openid-connect/auth`,
clientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: pending.state,
codeChallenge: challenge,
})
window.location.assign(url)
}
async function postTokenEndpoint(body: URLSearchParams): Promise<TokenResponse> {
const res = await fetch(`${authority}/protocol/openid-connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
let detail = ''
try {
const json = (await res.json()) as { error_description?: string; error?: string }
detail = json.error_description ?? json.error ?? ''
} catch {
/* body ไม่ใช่ JSON — ใช้ status อย่างเดียว */
}
throw new AuthError(detail || `Keycloak ตอบ HTTP ${res.status}`)
}
return (await res.json()) as TokenResponse
}
/** รับ code จากหน้า /auth/callback — validate state แล้วแลก token (เรียกจาก onMounted ของหน้า callback) */
async function handleCallback(query: LocationQuery): Promise<CallbackResult> {
if (typeof query.error === 'string')
return { ok: false, error: `การเข้าสู่ระบบถูกปฏิเสธ: ${query.error_description ?? query.error}` }
if (typeof query.code !== 'string' || typeof query.state !== 'string')
return { ok: false, error: 'ไม่พบ authorization code ใน URL' }
const pending = readPending()
sessionStorage.removeItem(PENDING_KEY)
if (pending === null)
return { ok: false, error: 'ไม่พบข้อมูลการเข้าสู่ระบบที่ค้างอยู่ (แท็บอาจถูกปิดระหว่างรอ) — ลองเข้าสู่ระบบใหม่' }
if (pending.state !== query.state)
return { ok: false, error: 'state ไม่ตรงกัน (อาจเป็นการโจมตี CSRF) — ยกเลิกการเข้าสู่ระบบ' }
try {
const tokens = await postTokenEndpoint(
new URLSearchParams({
grant_type: 'authorization_code',
code: query.code,
redirect_uri: `${window.location.origin}/auth/callback`,
client_id: clientId,
code_verifier: pending.verifier,
}),
)
setTokens(tokens)
// กัน open redirect — รับเฉพาะ path ภายในแอปเท่านั้น
const returnTo = pending.returnTo.startsWith('/') && !pending.returnTo.startsWith('//') ? pending.returnTo : '/'
return { ok: true, returnTo }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'แลก token ไม่สำเร็จ' }
}
}
/** refresh token — คืน token ชุดใหม่ / throw เมื่อ refresh token ตาย (ต้อง login ใหม่) */
async function refresh(): Promise<MemoryTokens> {
const currentRefresh = state.value.tokens?.refreshToken ?? readSession()?.refreshToken
if (!currentRefresh) {
state.value.tokens = null
state.value.username = null
writeSession(null)
throw new AuthError('ไม่มี session ให้ต่ออายุ — กรุณาเข้าสู่ระบบใหม่')
}
try {
const tokens = await postTokenEndpoint(
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: currentRefresh,
client_id: clientId,
}),
)
setTokens(tokens)
return state.value.tokens
} catch (err) {
// refresh token ตาย/ถูก revoke — เคลียร์ session แล้วให้ UI กลับไปหน้า login แบบ graceful
state.value.tokens = null
state.value.username = null
writeSession(null)
throw new AuthError(err instanceof Error ? `เซสชันหมดอายุ (${err.message}) — กรุณาเข้าสู่ระบบใหม่` : 'เซสชันหมดอายุ — กรุณาเข้าสู่ระบบใหม่')
}
}
/** access token ที่ยังใช้ได้ — refresh เงียบๆ ก่อนหมดอายุ (เหลือ < 30 วิ ถือว่าใกล้หมด) */
async function getAccessToken(forceRefresh = false): Promise<string> {
const t = state.value.tokens
if (!forceRefresh && t && t.expiresAt - 30_000 > Date.now()) return t.accessToken
if (refreshInFlight) return (await refreshInFlight).accessToken
refreshInFlight = refresh().finally(() => (refreshInFlight = null))
return (await refreshInFlight).accessToken
}
/** ตอน boot — มี session ใน sessionStorage ก็ต่ออาย่เพื่อกลับมา login อยู่โดยไม่ต้อง redirect */
async function restoreSession(): Promise<void> {
if (!import.meta.client) {
state.value.ready = true
return
}
try {
if (readSession()) await getAccessToken(true)
} catch {
/* refresh พัง = ไม่ login อยู่ — แค่นั้น */
} finally {
state.value.ready = true
}
}
/** ออกจากระบบ — เคลียร์ของในเครื่องทั้งหมดแล้วปิด session ฝั่ง Keycloak ด้วย */
function logout() {
if (!import.meta.client) return
const idToken = state.value.tokens?.idToken ?? readSession()?.idToken
state.value.tokens = null
state.value.username = null
sessionStorage.removeItem(PENDING_KEY)
writeSession(null)
const qs = new URLSearchParams({ post_logout_redirect_uri: `${window.location.origin}/` })
if (idToken) qs.set('id_token_hint', idToken)
window.location.assign(`${authority}/protocol/openid-connect/logout?${qs.toString()}`)
}
return { state, isAuthenticated, username: computed(() => state.value.username), ready: computed(() => state.value.ready), login, handleCallback, getAccessToken, restoreSession, logout }
}

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
/** ปลายทางของ OIDC redirect — รับ code+state แลก token แล้วพากลับหน้าที่ login จาก (ทำงานฝั่ง client ทั้งหมด) */
const route = useRoute()
const { handleCallback, login } = useAuth()
const message = ref('กำลังตรวจสอบผลการเข้าสู่ระบบ…')
const failed = ref(false)
const busy = ref(true)
onMounted(async () => {
const result = await handleCallback(route.query)
if (result.ok) {
// replace เพื่อเช็ด ?code=...&state=... ออกจาก history
await navigateTo(result.returnTo ?? '/', { replace: true })
return
}
failed.value = true
busy.value = false
message.value = result.error ?? 'เข้าสู่ระบบไม่สำเร็จ'
})
</script>
<template>
<section class="mx-auto max-w-xl space-y-4 py-10 text-center">
<template v-if="busy">
<div class="flex items-center justify-center gap-3 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
{{ message }}
</div>
</template>
<template v-else>
<AlertBox type="error">{{ message }}</AlertBox>
<div class="flex justify-center gap-3">
<button
type="button"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
@click="login()"
>
เข้าสู่ระบบใหม่
</button>
<NuxtLink
to="/"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
กลับหน้าหลัก
</NuxtLink>
</div>
</template>
</section>
</template>

View file

@ -0,0 +1,222 @@
<script setup lang="ts">
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด */
import type { GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const templateId = computed(() => String(route.params.templateId))
const detail = ref<TemplateDetail | null>(null)
const values = ref<Record<string, string>>({})
const issues = ref<Record<string, string>>({})
const loading = ref(true)
const loadError = ref<string | null>(null)
const submitting = ref(false)
const submitError = ref<string | null>(null)
const result = ref<GenerateDocumentResponse | null>(null)
const downloading = ref(false)
const downloadError = ref<string | null>(null)
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
useHead({
title: computed(() =>
detail.value
? `สร้างเอกสาร: ${detail.value.name} — e-Court Documents Versioning`
: 'สร้างเอกสาร — e-Court Documents Versioning',
),
})
async function load() {
loading.value = true
loadError.value = null
try {
detail.value = (await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!
values.value = Object.fromEntries(detail.value.fields.map((f) => [f.key, '']))
} catch (err) {
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
} finally {
loading.value = false
}
}
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
async function submit() {
submitError.value = null
downloadError.value = null
issues.value = Object.fromEntries(
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
)
if (Object.keys(issues.value).length > 0) return
submitting.value = true
try {
result.value = await apiJson<GenerateDocumentResponse>('/api/documents/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId: templateId.value, values: values.value }),
})
window.scrollTo({ top: 0 })
} catch (err) {
submitError.value = err instanceof Error ? err.message : 'สร้างเอกสารไม่สำเร็จ'
} finally {
submitting.value = false
}
}
async function download() {
if (!result.value) return
downloading.value = true
downloadError.value = null
try {
const name = detail.value ? `${detail.value.name}_v${result.value.versionNumber}.docx` : 'document_v1.docx'
await downloadFile(result.value.downloadUrl, name)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloading.value = false
}
}
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม */
function resetForm() {
result.value = null
submitError.value = null
downloadError.value = null
issues.value = {}
values.value = Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
}
onMounted(load)
</script>
<template>
<AuthGate>
<section class="mx-auto max-w-2xl space-y-6">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
กำลังโหลด template…
</div>
<AlertBox v-else-if="loadError" type="error">
{{ loadError }}
<NuxtLink to="/" class="ml-1 font-medium underline">กลับหน้าหลัก</NuxtLink>
</AlertBox>
<template v-else-if="detail">
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v1 แล้ว -->
<template v-if="result">
<AlertBox type="success">
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
</AlertBox>
<div class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">
<h1 class="text-xl font-bold">{{ detail.name }}</h1>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex gap-2">
<dt class="w-32 shrink-0 text-slate-500 dark:text-slate-400">document id</dt>
<dd class="break-all font-mono text-xs leading-6">{{ result.documentId }}</dd>
</div>
<div class="flex gap-2">
<dt class="w-32 shrink-0 text-slate-500 dark:text-slate-400">version</dt>
<dd>{{ result.versionNumber }} (ต้นทางของ version history)</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
:disabled="downloading"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
@click="download"
>
{{ downloading ? 'กำลังเตรียมไฟล์…' : '⬇ ดาวน์โหลด .docx' }}
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="resetForm"
>
สร้างอีกฉบับ
</button>
<NuxtLink
to="/"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
กลับหน้าหลัก
</NuxtLink>
</div>
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
</div>
<div class="rounded-md border border-sky-200 bg-sky-50 p-4 text-sm dark:border-sky-800 dark:bg-sky-950">
<p class="font-medium text-sky-800 dark:text-sky-300">ขั้นต่อไป</p>
<ol class="mt-2 list-decimal space-y-1 pl-5 text-sky-800 dark:text-sky-200">
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
<li>
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
tracked document และแสดง <strong>version 1</strong> ทันที โดยไม่ต้องกด Start Versioning
</li>
<li>แก้ไขเอกสารต่อแล้วกด <strong>Save &amp; Version</strong> เพื่อบันทึก version ถัดไป</li>
</ol>
</div>
</template>
<!-- ฟอร์มกรอกค่า -->
<form v-else class="space-y-5" @submit.prevent="submit" novalidate>
<div>
<h1 class="text-2xl font-bold">{{ detail.name }}</h1>
<p v-if="detail.description" class="mt-1 text-sm text-slate-600 dark:text-slate-300">
{{ detail.description }}
</p>
</div>
<div
v-for="f in sortedFields"
:key="f.key"
>
<label :for="`field-${f.key}`" class="mb-1 block text-sm font-medium">
{{ f.label }}
<span v-if="f.isRequired" class="text-red-500">*</span>
<span class="ml-1 font-mono text-xs font-normal text-slate-400">{{ f.key }}</span>
</label>
<input
:id="`field-${f.key}`"
v-model="values[f.key]"
:type="f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'"
:step="f.type === 'number' ? 'any' : undefined"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
:class="issues[f.key] ? 'border-red-400 dark:border-red-500' : ''"
/>
<p v-if="issues[f.key]" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ issues[f.key] }}</p>
</div>
<div
v-if="sortedFields.length === 0"
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
template นี้ไม่มี field definitions —
<NuxtLink :to="`/templates/${detail.id}/edit`" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
ตรวจสอบที่หน้าจัดการ template
</NuxtLink>
</div>
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
<button
type="submit"
:disabled="submitting || sortedFields.length === 0"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
</button>
</form>
</template>
</section>
</AuthGate>
</template>

View file

@ -1,15 +1,71 @@
<script setup lang="ts">
/** FR-10 UI — หน้าหลัก: card รายการ template ที่ active ให้เลือกสร้างเอกสาร */
import type { TemplateListItem } from '~/types/api'
const { apiJson } = useApi()
const templates = ref<TemplateListItem[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
try {
templates.value = (await apiJson<TemplateListItem[]>('/api/templates')) ?? []
} catch (err) {
error.value = err instanceof Error ? err.message : 'โหลดรายการ template ไม่สำเร็จ'
} finally {
loading.value = false
}
})
</script>
<template>
<section class="space-y-4">
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
<p class="text-slate-600 dark:text-slate-300">
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น version 1 พร้อม metadata
ไปเปิดต่อใน Word พร้อม add-in
</p>
<!-- Phase 6: รายการ template ที่ active + ลิงก์เข้าหน้า documents/create/[templateId] -->
<div
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
รายการ template จะแสดงที่นี่ (Phase 6)
</div>
</section>
<AuthGate>
<section class="space-y-5">
<div>
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น <strong>version 1</strong> พร้อม metadata
ไว้เปิดต่อใน Word พร้อม add-in
</p>
</div>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
กำลังโหลดรายการ template…
</div>
<AlertBox v-else-if="error" type="error">{{ error }}</AlertBox>
<div
v-else-if="templates.length === 0"
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
ยังไม่มี template — ไปที่
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
สร้าง template ตัวแรก
</NuxtLink>
</div>
<div v-else class="grid gap-4 sm:grid-cols-2">
<NuxtLink
v-for="t in templates"
:key="t.id"
:to="`/documents/create/${t.id}`"
class="block rounded-lg border border-slate-200 bg-white p-5 transition hover:border-sky-400 hover:shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-500"
>
<h2 class="font-semibold">{{ t.name }}</h2>
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-slate-600 dark:text-slate-300">
{{ t.description ?? '—' }}
</p>
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
</p>
<p class="mt-3 text-sm font-medium text-sky-600 dark:text-sky-400">สร้างเอกสาร →</p>
</NuxtLink>
</div>
</section>
</AuthGate>
</template>

View file

@ -0,0 +1,325 @@
<script setup lang="ts">
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/required/ลำดับ) + แทนที่ไฟล์ */
import type { FieldType, ReplaceTemplateFileResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
const { apiJson, apiUpload } = useApi()
useHead({ title: 'แก้ไข template — e-Court Documents Versioning' })
const templateId = computed(() => String(route.params.id))
const FIELD_TYPES: FieldType[] = ['text', 'number', 'date']
const detail = ref<TemplateDetail | null>(null)
const loading = ref(true)
const loadError = ref<string | null>(null)
// ฟอร์ม metadata
const name = ref('')
const description = ref('')
const isActive = ref(true)
// ฟอร์ม field defs (แก้ใน local copy แล้วส่งทั้งชุดตอนบันทึก)
const fields = ref<(TemplateDetail['fields'][number] & { label: string })[]>([])
const saving = ref(false)
const saveError = ref<string | null>(null)
const savedAt = ref<string | null>(null)
const replaceFile = ref<File | null>(null)
const replaceInput = ref<HTMLInputElement | null>(null)
const replacing = ref(false)
const replaceError = ref<string | null>(null)
const replaceResult = ref<ReplaceTemplateFileResponse | null>(null)
function applyDetail(d: TemplateDetail) {
detail.value = d
name.value = d.name
description.value = d.description ?? ''
isActive.value = d.isActive
fields.value = d.fields.map((f) => ({ ...f }))
}
async function load() {
loading.value = true
loadError.value = null
try {
applyDetail((await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!)
} catch (err) {
loadError.value = err instanceof Error ? err.message : 'โหลดข้อมูล template ไม่สำเร็จ'
} finally {
loading.value = false
}
}
async function save() {
saveError.value = null
savedAt.value = null
if (!name.value.trim()) {
saveError.value = 'ชื่อ template ห้ามว่าง'
return
}
if (fields.value.some((f) => !f.label.trim())) {
saveError.value = 'ทุก field ต้องมี label'
return
}
saving.value = true
try {
const updated = await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value.trim(),
description: description.value.trim() || null,
isActive: isActive.value,
fields: fields.value.map((f) => ({
key: f.key,
label: f.label.trim(),
type: f.type,
isRequired: f.isRequired,
sortOrder: f.sortOrder,
})),
}),
})
applyDetail(updated!)
savedAt.value = new Date().toLocaleTimeString('th-TH')
} catch (err) {
saveError.value = err instanceof Error ? err.message : 'บันทึกไม่สำเร็จ'
} finally {
saving.value = false
}
}
function onReplaceFileChange(e: Event) {
const input = e.target as HTMLInputElement
replaceFile.value = input.files?.[0] ?? null
}
async function submitReplaceFile() {
replaceError.value = null
replaceResult.value = null
if (!replaceFile.value) {
replaceError.value = 'เลือกไฟล์ .docx ใหม่ก่อน'
return
}
if (!replaceFile.value.name.toLowerCase().endsWith('.docx')) {
replaceError.value = 'รองรับเฉพาะไฟล์ .docx เท่านั้น'
return
}
const form = new FormData()
form.append('file', replaceFile.value)
replacing.value = true
try {
const result = await apiUpload<ReplaceTemplateFileResponse>(
`/api/templates/${templateId.value}/file`,
form,
'PUT',
)
replaceResult.value = result
await load()
replaceFile.value = null
if (replaceInput.value) replaceInput.value.value = ''
} catch (err) {
replaceError.value = err instanceof Error ? err.message : 'แทนที่ไฟล์ไม่สำเร็จ'
} finally {
replacing.value = false
}
}
onMounted(load)
</script>
<template>
<AuthGate>
<section class="mx-auto max-w-4xl space-y-8">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
กำลังโหลด…
</div>
<AlertBox v-else-if="loadError" type="error">{{ loadError }}</AlertBox>
<template v-else-if="detail">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ detail.name }}</h1>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
สร้าง {{ formatDateTime(detail.createdAt) }} · อัปเดต {{ formatDateTime(detail.updatedAt) }}
</p>
</div>
<NuxtLink
to="/templates"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
← รายการ template
</NuxtLink>
</div>
<!-- 1) metadata -->
<form class="space-y-4" @submit.prevent="save">
<h2 class="text-lg font-semibold">ข้อมูล template</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="edit-name" class="mb-1 block text-sm font-medium">ชื่อ</label>
<input
id="edit-name"
v-model="name"
type="text"
required
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</div>
<div class="flex items-end gap-2 pb-1">
<input
id="edit-active"
v-model="isActive"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
/>
<label for="edit-active" class="text-sm">
active
<span class="text-xs text-slate-500 dark:text-slate-400">(เอาออก = สร้างเอกสารใหม่ไม่ได้ แต่เอกสารเดิมยังใช้ได้)</span>
</label>
</div>
</div>
<div>
<label for="edit-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</label>
<textarea
id="edit-desc"
v-model="description"
rows="2"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</div>
</form>
<!-- 2) field definitions -->
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold">Field definitions</h2>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
<span class="font-mono">key</span> มาจาก content control tag ในไฟล์ — เพิ่ม/ลบ key ต้องแทนที่ไฟล์ด้านล่าง
</p>
</div>
<div class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">key</th>
<th class="px-4 py-3">label</th>
<th class="px-4 py-3">type</th>
<th class="px-4 py-3 text-center">required</th>
<th class="px-4 py-3">ลำดับ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="(f, i) in fields" :key="f.key">
<td class="px-4 py-2 font-mono text-xs">{{ f.key }}</td>
<td class="px-4 py-2">
<input
v-model="f.label"
type="text"
:aria-label="`label ของ ${f.key}`"
class="w-full min-w-40 rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</td>
<td class="px-4 py-2">
<select
v-model="f.type"
:aria-label="`type ของ ${f.key}`"
class="rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</td>
<td class="px-4 py-2 text-center">
<input
v-model="f.isRequired"
type="checkbox"
:aria-label="`required ของ ${f.key}`"
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
/>
</td>
<td class="px-4 py-2">
<input
v-model.number="f.sortOrder"
type="number"
:aria-label="`ลำดับของ ${f.key}`"
class="w-20 rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</td>
</tr>
<tr v-if="fields.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
ไฟล์นี้ไม่มี content control เลย — แทนที่ไฟล์ด้วยไฟล์ที่มี content controls
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
:disabled="saving"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
@click="save"
>
{{ saving ? 'กำลังบันทึก…' : 'บันทึกทั้งหมด' }}
</button>
<span v-if="savedAt && !saveError" class="text-xs text-emerald-600 dark:text-emerald-400">
บันทึกแล้วเมื่อ {{ savedAt }}
</span>
</div>
<AlertBox v-if="saveError" type="error">{{ saveError }}</AlertBox>
</div>
<!-- 3) replace file -->
<div class="space-y-3 border-t border-slate-200 pt-6 dark:border-slate-700">
<div>
<h2 class="text-lg font-semibold">แทนที่ไฟล์ template</h2>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
ระบบจะ detect tags ใหม่จากไฟล์ — field definition ของ tag ที่หายไปจะถูกลบ, tag ใหม่จะเพิ่มแบบ default
(เอกสารที่สร้างไปแล้วไม่กระทบ)
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<input
ref="replaceInput"
type="file"
accept=".docx"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1 file:text-sm dark:border-slate-600 dark:bg-slate-800 dark:file:bg-slate-700"
@change="onReplaceFileChange"
/>
<button
type="button"
:disabled="replacing || !replaceFile"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:hover:bg-slate-800"
@click="submitReplaceFile"
>
{{ replacing ? 'กำลังอัปโหลด…' : 'อัปโหลดไฟล์ใหม่' }}
</button>
</div>
<AlertBox v-if="replaceError" type="error">{{ replaceError }}</AlertBox>
<AlertBox v-if="replaceResult" type="success">
แทนที่ไฟล์สำเร็จ —
เพิ่ม {{ replaceResult.addedTags.length }} tags
<span v-if="replaceResult.addedTags.length" class="font-mono">({{ replaceResult.addedTags.join(', ') }})</span>,
ลบ {{ replaceResult.removedTags.length }} tags
<span v-if="replaceResult.removedTags.length" class="font-mono">({{ replaceResult.removedTags.join(', ') }})</span>
— ตรวจและบันทึก field definitions ด้านบนอีกครั้ง
</AlertBox>
</div>
</template>
</section>
</AuthGate>
</template>

View file

@ -0,0 +1,167 @@
<script setup lang="ts">
/** FR-9 UI — รายการ template ทั้งหมด (admin): ดู/แก้ไข/activate-deactivate */
import type { TemplateListItem } from '~/types/api'
const { apiJson } = useApi()
useHead({ title: 'จัดการ template — e-Court Documents Versioning' })
const templates = ref<TemplateListItem[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showInactive = ref(false)
const busyId = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const qs = showInactive.value ? '?includeInactive=true' : ''
templates.value = (await apiJson<TemplateListItem[]>(`/api/templates${qs}`)) ?? []
} catch (err) {
error.value = err instanceof Error ? err.message : 'โหลดรายการ template ไม่สำเร็จ'
} finally {
loading.value = false
}
}
/** deactivate = DELETE (soft-delete), activate กลับ = PUT { isActive: true } */
async function toggleActive(t: TemplateListItem) {
const verb = t.isActive ? 'deactivate' : 'activate'
if (!window.confirm(`ยืนยัน${verb === 'deactivate' ? 'การ deactivate' : 'การ activate'} template “${t.name}”?`)) return
busyId.value = t.id
error.value = null
try {
if (t.isActive) await apiJson(`/api/templates/${t.id}`, { method: 'DELETE' })
else await apiJson(`/api/templates/${t.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: true }),
})
await load()
} catch (err) {
error.value = err instanceof Error ? err.message : `${verb} ไม่สำเร็จ`
} finally {
busyId.value = null
}
}
watch(showInactive, load)
onMounted(load)
</script>
<template>
<AuthGate>
<section class="space-y-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">จัดการ template</h1>
<NuxtLink
to="/templates/new"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
>
+ สร้าง template ใหม่
</NuxtLink>
</div>
<label class="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<input
v-model="showInactive"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
/>
แสดง template ที่ deactivate ไว้ด้วย
</label>
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
กำลังโหลด…
</div>
<AlertBox v-else-if="error" type="error">{{ error }}</AlertBox>
<div
v-else-if="templates.length === 0"
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
ยังไม่มี template{{ showInactive ? ' เลย' : 'ที่ active' }} —
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
สร้าง template แรก
</NuxtLink>
</div>
<div
v-else
class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"
>
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">ชื่อ</th>
<th class="px-4 py-3">คำอธิบาย</th>
<th class="px-4 py-3 text-center">fields</th>
<th class="px-4 py-3">สถานะ</th>
<th class="px-4 py-3">อัปเดตล่าสุด</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="t in templates" :key="t.id" class="align-top">
<td class="px-4 py-3 font-medium">
<NuxtLink
:to="`/templates/${t.id}/edit`"
class="text-sky-700 hover:underline dark:text-sky-400"
>
{{ t.name }}
</NuxtLink>
</td>
<td class="max-w-60 px-4 py-3 text-slate-600 dark:text-slate-300">{{ t.description ?? '—' }}</td>
<td class="px-4 py-3 text-center">{{ t.fieldCount }}</td>
<td class="px-4 py-3">
<span
v-if="t.isActive"
class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300"
>
active
</span>
<span
v-else
class="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300"
>
deactivated
</span>
</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(t.updatedAt) }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<NuxtLink
:to="`/templates/${t.id}/edit`"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
แก้ไข
</NuxtLink>
<button
type="button"
:disabled="busyId === t.id"
class="ml-2 rounded-md border px-2.5 py-1 text-xs disabled:opacity-50"
:class="
t.isActive
? 'border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950'
: 'border-emerald-300 text-emerald-600 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950'
"
@click="toggleActive(t)"
>
{{ busyId === t.id ? '…' : t.isActive ? 'deactivate' : 'activate' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">
การ deactivate เป็น soft-delete — เอกสารที่สร้างจาก template นั้นไปแล้วยังใช้งานต่อได้ตามปกติ
</p>
</section>
</AuthGate>
</template>

View file

@ -0,0 +1,164 @@
<script setup lang="ts">
/** FR-9 UI — upload template ใหม่: ระบบ detect fields (SDT tags) จากไฟล์ให้อัตโนมัติ */
import type { CreateTemplateResponse } from '~/types/api'
const { apiUpload } = useApi()
useHead({ title: 'สร้าง template ใหม่ — e-Court Documents Versioning' })
const name = ref('')
const description = ref('')
const file = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const submitting = ref(false)
const error = ref<string | null>(null)
const created = ref<CreateTemplateResponse | null>(null)
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
file.value = input.files?.[0] ?? null
}
async function submit() {
error.value = null
if (!name.value.trim()) {
error.value = 'กรอกชื่อ template ด้วย'
return
}
if (!file.value) {
error.value = 'เลือกไฟล์ .docx ของ template ด้วย'
return
}
if (!file.value.name.toLowerCase().endsWith('.docx')) {
error.value = 'รองรับเฉพาะไฟล์ .docx เท่านั้น'
return
}
const form = new FormData()
form.append('name', name.value.trim())
if (description.value.trim()) form.append('description', description.value.trim())
form.append('file', file.value)
submitting.value = true
try {
created.value = await apiUpload<CreateTemplateResponse>('/api/templates', form)
} catch (err) {
error.value = err instanceof Error ? err.message : 'อัปโหลด template ไม่สำเร็จ'
} finally {
submitting.value = false
}
}
/** เคลียร์ฟอร์มเพื่ออัปโหลดไฟล์ถัดไป */
function resetForm() {
created.value = null
name.value = ''
description.value = ''
file.value = null
if (fileInput.value) fileInput.value.value = ''
}
</script>
<template>
<AuthGate>
<section class="mx-auto max-w-2xl space-y-5">
<h1 class="text-2xl font-bold">สร้าง template ใหม่</h1>
<!-- ผลลัพธ์หลังอัปโหลดสำเร็จ: field list ที่ detect ได้จากไฟล์ -->
<template v-if="created">
<AlertBox type="success">
อัปโหลดสำเร็จ — ตรวจพบ <strong>{{ created.fields.length }}</strong> fields จากไฟล์
</AlertBox>
<div class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">key (SDT tag)</th>
<th class="px-4 py-3">label เริ่มต้น</th>
<th class="px-4 py-3">type เริ่มต้น</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="f in created.fields" :key="f.key">
<td class="px-4 py-2 font-mono text-xs">{{ f.key }}</td>
<td class="px-4 py-2">{{ f.label }}</td>
<td class="px-4 py-2">{{ f.type }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex gap-3">
<NuxtLink
:to="`/templates/${created.id}/edit`"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
>
ตั้งค่า label/type/required ต่อ →
</NuxtLink>
<button
type="button"
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
@click="resetForm"
>
อัปโหลดอีกไฟล์
</button>
</div>
</template>
<form v-else class="space-y-4" @submit.prevent="submit">
<AlertBox v-if="error" type="error">{{ error }}</AlertBox>
<div>
<label for="tpl-name" class="mb-1 block text-sm font-medium">ชื่อ template <span class="text-red-500">*</span></label>
<input
id="tpl-name"
v-model="name"
type="text"
required
placeholder="เช่น สัญญาจ้างงาน"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</div>
<div>
<label for="tpl-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</label>
<textarea
id="tpl-desc"
v-model="description"
rows="2"
placeholder="คำอธิบายสั้นๆ ว่าเอกสารนี้ใช้ทำอะไร"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
/>
</div>
<div>
<label for="tpl-file" class="mb-1 block text-sm font-medium">ไฟล์ template (.docx) <span class="text-red-500">*</span></label>
<input
id="tpl-file"
ref="fileInput"
type="file"
accept=".docx"
required
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1 file:text-sm dark:border-slate-600 dark:bg-slate-800 dark:file:bg-slate-700"
@change="onFileChange"
/>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
สร้างไฟล์ด้วย Word แล้วใส่
<strong>Content Controls</strong> (Developer → Rich Text/Plain Text Control) โดยตั้งค่า
<span class="font-mono">Tag</span> ของแต่ละ control เป็น field key — ระบบจะ detect tags
ออกมาเป็นรายการ fields ให้อัตโนมัติ
</p>
</div>
<button
type="submit"
:disabled="submitting"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
>
{{ submitting ? 'กำลังอัปโหลด…' : 'อัปโหลด template' }}
</button>
</form>
</section>
</AuthGate>
</template>

View file

@ -0,0 +1,8 @@
/**
* ต่ออายุ session ตอนแอป boot (client เท่านั้น) — ถ้ามี refresh token ใน sessionStorage
* ก็ได้ access token กลับมาโดยไม่ต้อง redirect ไป Keycloak ใหม่
* เป็น async plugin เพื่อรอผลก่อนหน้าแรก render (กัน UI กระพริบไปหน้า login แป๊บนึง)
*/
export default defineNuxtPlugin(async () => {
await useAuth().restoreSession()
})

56
web/app/types/api.ts Normal file
View file

@ -0,0 +1,56 @@
/** DTO ตรงกับ JSON ที่ backend คืน (camelCase — ดู Controllers/*.cs + *Dtos.cs) */
export type FieldType = 'text' | 'number' | 'date'
export interface TemplateFieldDto {
id: string
/** = SDT tag ในไฟล์ template — ใช้เป็น key ของ values ตอน generate */
key: string
label: string
type: FieldType | string
isRequired: boolean
sortOrder: number
}
export interface TemplateListItem {
id: string
name: string
description: string | null
isActive: boolean
fieldCount: number
createdAt: string
updatedAt: string
}
export interface TemplateDetail {
id: string
name: string
description: string | null
isActive: boolean
createdAt: string
updatedAt: string
fields: TemplateFieldDto[]
}
/** response ของ POST /api/templates — fields ที่ detect อัตโนมัติพร้อมค่า default */
export interface CreateTemplateResponse {
id: string
fields: TemplateFieldDto[]
}
/** response ของ PUT /api/templates/{id}/file */
export interface ReplaceTemplateFileResponse {
id: string
fields: TemplateFieldDto[]
addedTags: string[]
removedTags: string[]
}
/** response ของ POST /api/documents/generate (FR-10) */
export interface GenerateDocumentResponse {
documentId: string
versionId: string
versionNumber: number
/** path สัมพัทธ์ของ backend — เติม apiBaseUrl ตอนเรียก */
downloadUrl: string
}

View file

@ -0,0 +1,41 @@
/**
* Validation ฝั่ง client ของ dynamic form สร้างเอกสาร (FR-10) — pure function ให้ unit test ได้
* เงื่อนไขตรงกับ backend (DocumentsController.Generate): required ห้ามว่าง,
* number ต้อง parse เป็นเลขได้, date ต้องเป็นวันที่ ISO ที่ parse ได้ — client เช็คก่อนเพื่อ feedback ทันที
*/
export interface FieldDefLike {
key: string
label: string
type: string
isRequired: boolean
}
export interface FieldIssue {
key: string
message: string
}
/** ตัวเลข: จำนวนเต็ม/ทศนิยม ติดลบได้ มี comma คั่นหลักได้ (ตรง NumberStyles.Number ของ backend) */
const NUMBER_RE = /^[+-]?\d+(?:\.\d+)?$/
export function validateFieldValues(
defs: FieldDefLike[],
values: Record<string, string>,
): FieldIssue[] {
const issues: FieldIssue[] = []
for (const def of defs) {
const raw = (values[def.key] ?? '').trim()
if (raw === '') {
if (def.isRequired) issues.push({ key: def.key, message: `“${def.label}” เป็นช่องที่ต้องกรอก` })
continue
}
if (def.type === 'number' && !NUMBER_RE.test(raw.replace(/,/g, ''))) {
issues.push({ key: def.key, message: `“${def.label}” ต้องเป็นตัวเลข` })
}
if (def.type === 'date' && Number.isNaN(Date.parse(raw))) {
issues.push({ key: def.key, message: `“${def.label}” ต้องเป็นวันที่ (YYYY-MM-DD)` })
}
}
return issues
}

6
web/app/utils/format.ts Normal file
View file

@ -0,0 +1,6 @@
/** format วันที่เป็นภาษาไทย — เรียกจากฝั่ง client เท่านั้น (ข้อมูล API ดึงหลัง mount จึงไม่มีปัญหา hydration) */
export function formatDateTime(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('th-TH', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}

75
web/app/utils/oidc.ts Normal file
View file

@ -0,0 +1,75 @@
/**
* Helpers สำหรับ OIDC + PKCE — เป็น pure functions (แยกจาก useAuth) เพื่อ unit test ได้ง่าย
* เว็บเป็น top-level page จึงใช้ browser redirect ปกติได้ (ไม่ต้องมี Office Dialog API แบบ add-in)
*/
/** เข้ารหัส bytes เป็น base64url (ไม่มี padding) ตาม RFC 7636 */
export function toBase64Url(bytes: Uint8Array): string {
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** code_verifier ของ PKCE — 48 random bytes → base64url 64 อักขระ (อยู่ในช่วง 43–128 ตาม spec) */
export function generateCodeVerifier(): string {
const bytes = new Uint8Array(48)
crypto.getRandomValues(bytes)
return toBase64Url(bytes)
}
/** state สุ่มสำหรับกัน CSRF — เทียบตอนรับ code กลับ */
export function generateState(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return toBase64Url(bytes)
}
/** code_challenge = base64url(SHA-256(verifier)) วิธี S256 */
export async function createCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return toBase64Url(new Uint8Array(digest))
}
export interface AuthorizeUrlParams {
authorizeEndpoint: string
clientId: string
redirectUri: string
state: string
codeChallenge: string
scope?: string
}
/** ประกอบ authorization URL แบบ authorization code + PKCE S256 */
export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
const qs = new URLSearchParams({
client_id: params.clientId,
response_type: 'code',
scope: params.scope ?? 'openid',
redirect_uri: params.redirectUri,
state: params.state,
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
})
return `${params.authorizeEndpoint}?${qs.toString()}`
}
/** รูป response ของ token endpoint (เฉพาะ field ที่เราใช้) */
export interface TokenResponse {
access_token: string
refresh_token?: string
id_token?: string
expires_in?: number
}
/** decode payload ของ JWT เพื่ออ่าน claims (exp / preferred_username) — token ไม่ถูกต้องคืน null ไม่ throw */
export function parseJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.')
if (parts.length !== 3) return null
try {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
} catch {
return null
}
}

View file

@ -11,6 +11,18 @@ export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
// config ทั้งหมดแยกเป็น env ได้ (กติกา: ห้าม hardcode) — override ด้วย env
// NUXT_PUBLIC_API_BASE_URL / NUXT_PUBLIC_KEYCLOAK_AUTHORITY / NUXT_PUBLIC_KEYCLOAK_CLIENT_ID
runtimeConfig: {
public: {
// เว็บเป็น top-level http page → เรียก backend ด้วย http ได้ (ไม่มีปัญหา mixed content แบบ task pane)
apiBaseUrl: 'http://localhost:5075',
// Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง)
keycloakAuthority: 'https://localhost:8443/realms/word-versioning',
keycloakClientId: 'word-versioning-web',
},
},
// ~/assets/css/tailwind.css ถูก @nuxtjs/tailwindcss pick up อัตโนมัติ
app: {