phase 6 เบื้องต้น
This commit is contained in:
parent
1bb850c6b8
commit
93570d38d8
20 changed files with 1715 additions and 34 deletions
41
web/app/utils/fieldValidation.ts
Normal file
41
web/app/utils/fieldValidation.ts
Normal 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
6
web/app/utils/format.ts
Normal 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
75
web/app/utils/oidc.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue