From 5f35135300b5141feadee79262c4a66605dfdc14 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 17 Aug 2026 21:04:12 +0900 Subject: [PATCH] feat(01-06): AEG-VS-01-06 Vue Feature Development - Identity Management Page Implements Vue 3 Identity Management page with: - Identity registration form with Zod validation - Email (required, valid format, lowercase), displayName (required, max 255, trimmed) - List view with search/filter (by state and MFA requirement) - Simple create modal + delete functionality with confirmation - useIdentityApi composable integrating with RegisterIdentity endpoint - Full TypeScript validation (15 tests pass: identitySchema + useIdentityApi) - Responsive table display with status badges Technical approach: - Simplified component using minimal KsTextField/KsSelect/KsButton - Avoided complex component wrapper conflicts (prior session issue) - Mock data for demo, real API calls ready - Router integration: /system/identities (SCR-SYS-002) - CSS scoped styling for modal, form, table, badges - Error/success message handling per state AGENTS.md v16.0: SOLID principles (Single Responsibility), Composition API (reactive state management), Zod schema enforces data consistency, no over-engineering Co-Authored-By: Claude Haiku 4.5 --- frontend/src/app/router.ts | 1 + .../__tests__/useIdentityApi.test.ts | 129 +++++++ .../system/composables/useIdentityApi.ts | 127 +++++++ .../system/pages/IdentityManagementPage.vue | 359 ++++++++++++++++++ .../types/__tests__/identitySchema.test.ts | 118 ++++++ .../features/system/types/identitySchema.ts | 61 +++ 6 files changed, 795 insertions(+) create mode 100644 frontend/src/features/system/composables/__tests__/useIdentityApi.test.ts create mode 100644 frontend/src/features/system/composables/useIdentityApi.ts create mode 100644 frontend/src/features/system/pages/IdentityManagementPage.vue create mode 100644 frontend/src/features/system/types/__tests__/identitySchema.test.ts create mode 100644 frontend/src/features/system/types/identitySchema.ts diff --git a/frontend/src/app/router.ts b/frontend/src/app/router.ts index 8a809c12..bfeda872 100644 --- a/frontend/src/app/router.ts +++ b/frontend/src/app/router.ts @@ -23,6 +23,7 @@ export const router = createRouter({ { path: '/model-ops/shadow-run-jobs', component: () => import('../features/shadow-run/pages/ShadowRunQueue.vue'), meta: { screenId: 'model-ops.shadow-run.queue', module: 'ModelOps', title: 'Shadow Run Jobs', permissions: ['model.read'] } }, { path: '/model-ops/models-master', component: () => import('../features/models/pages/ModelList.vue'), meta: { screenId: 'model-ops.models.master', module: 'ModelOps', title: 'Models (Master-Detail)', permissions: ['model.read'] } }, { path: '/system/common-codes', component: () => import('../features/system/pages/CommonCodeManagementPage.vue'), meta: { screenId: 'SCR-SYS-001', templateId: 'T01', module: 'System', section: 'System', title: '공통코드 관리', order: 1, favoriteAllowed: true } }, + { path: '/system/identities', component: () => import('../features/system/pages/IdentityManagementPage.vue'), meta: { screenId: 'SCR-SYS-002', templateId: 'T01', module: 'System', section: 'System', title: '항등성 관리', order: 2, favoriteAllowed: true } }, { path: '/governance/approvals', component: () => import('../features/approval/pages/ApprovalQueue.vue'), meta: { screenId: 'governance.approval.queue', module: 'Governance', title: 'Approval Queue', permissions: ['approval.review'] } } ] }) diff --git a/frontend/src/features/system/composables/__tests__/useIdentityApi.test.ts b/frontend/src/features/system/composables/__tests__/useIdentityApi.test.ts new file mode 100644 index 00000000..8a578400 --- /dev/null +++ b/frontend/src/features/system/composables/__tests__/useIdentityApi.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useIdentityApi } from '../useIdentityApi' +import type { RegisterIdentityRequest } from '../../types/identitySchema' + +describe('useIdentityApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('registerIdentity', () => { + it('should successfully register a new identity', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: '550e8400-e29b-41d4-a716-446655440001', + email: 'test@example.com', + state: 'ACTIVE', + }), + }) + + const { registerIdentity, loading } = useIdentityApi() + + const request: RegisterIdentityRequest = { + email: 'test@example.com', + displayName: 'Test User', + } + + const response = await registerIdentity(request) + + expect(response).toEqual({ + id: '550e8400-e29b-41d4-a716-446655440001', + email: 'test@example.com', + state: 'ACTIVE', + }) + expect(loading.value).toBe(false) + }) + + it('should handle HTTP errors gracefully', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ message: 'Email already registered' }), + }) + + const { registerIdentity, error } = useIdentityApi() + + const response = await registerIdentity({ + email: 'existing@example.com', + displayName: 'User', + }) + + expect(response).toBeNull() + expect(error.value).toBe('Email already registered') + }) + + it('should handle network errors', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error')) + + const { registerIdentity, error } = useIdentityApi() + + const response = await registerIdentity({ + email: 'test@example.com', + displayName: 'User', + }) + + expect(response).toBeNull() + expect(error.value).toBe('Network error') + }) + }) + + describe('state management', () => { + it('should track loading state during request', async () => { + global.fetch = vi.fn().mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ + ok: true, + json: async () => ({ id: '1', email: 'test@example.com', state: 'ACTIVE' }), + }), 10)) + ) + + const { registerIdentity, loading } = useIdentityApi() + + expect(loading.value).toBe(false) + + const promise = registerIdentity({ + email: 'test@example.com', + displayName: 'User', + }) + + // Loading should be true immediately after call + expect(loading.value).toBe(true) + + await promise + + // Loading should be false after completion + expect(loading.value).toBe(false) + }) + + it('should clear error on successful request', async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ message: 'Server error' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: '1', email: 'test@example.com', state: 'ACTIVE' }), + }) + + const { registerIdentity, error } = useIdentityApi() + + // First call fails + await registerIdentity({ + email: 'test@example.com', + displayName: 'User', + }) + + expect(error.value).toBe('Server error') + + // Second call succeeds + await registerIdentity({ + email: 'test@example.com', + displayName: 'User', + }) + + expect(error.value).toBeNull() + }) + }) +}) diff --git a/frontend/src/features/system/composables/useIdentityApi.ts b/frontend/src/features/system/composables/useIdentityApi.ts new file mode 100644 index 00000000..ee7ca3fa --- /dev/null +++ b/frontend/src/features/system/composables/useIdentityApi.ts @@ -0,0 +1,127 @@ +import { ref, computed } from 'vue' +import type { RegisterIdentityRequest, RegisterIdentityResponse, Identity, IdentityListResponse } from '../types/identitySchema' + +const API_BASE = '/api' + +export function useIdentityApi() { + const loading = ref(false) + const error = ref(null) + const identities = ref([]) + const total = ref(0) + + // Register new identity + const registerIdentity = async (data: RegisterIdentityRequest): Promise => { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/identities`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-KArtSell-User': 'current-user', // Will be replaced with actual auth token + 'X-KArtSell-Role': 'Admin', + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error' })) + throw new Error(errorData.message || `HTTP ${response.status}`) + } + + const result = await response.json() + return result + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to register identity' + console.error('Register identity error:', err) + return null + } finally { + loading.value = false + } + } + + // Get identity details + const getIdentity = async (identityId: string): Promise => { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/identities/${identityId}`, { + headers: { + 'X-KArtSell-User': 'current-user', + 'X-KArtSell-Role': 'Admin', + }, + }) + + if (!response.ok) throw new Error(`HTTP ${response.status}`) + + const data = await response.json() + return data + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch identity' + return null + } finally { + loading.value = false + } + } + + // List identities (mock for now, replace with actual API call) + const listIdentities = async (page = 1, pageSize = 20): Promise => { + loading.value = true + error.value = null + + try { + // TODO: Replace with actual API call when endpoint is available + // For now, mock data + identities.value = [] + total.value = 0 + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch identities' + } finally { + loading.value = false + } + } + + // Delete identity + const deleteIdentity = async (identityId: string): Promise => { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/identities/${identityId}`, { + method: 'DELETE', + headers: { + 'X-KArtSell-User': 'current-user', + 'X-KArtSell-Role': 'Admin', + }, + }) + + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return true + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to delete identity' + return false + } finally { + loading.value = false + } + } + + return { + // State + loading, + error, + identities, + total, + + // Computed + hasError: computed(() => error.value !== null), + isLoading: computed(() => loading.value), + + // Methods + registerIdentity, + getIdentity, + listIdentities, + deleteIdentity, + } +} diff --git a/frontend/src/features/system/pages/IdentityManagementPage.vue b/frontend/src/features/system/pages/IdentityManagementPage.vue new file mode 100644 index 00000000..7d3e45e3 --- /dev/null +++ b/frontend/src/features/system/pages/IdentityManagementPage.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/frontend/src/features/system/types/__tests__/identitySchema.test.ts b/frontend/src/features/system/types/__tests__/identitySchema.test.ts new file mode 100644 index 00000000..ccf839c9 --- /dev/null +++ b/frontend/src/features/system/types/__tests__/identitySchema.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest' +import { identityFormSchema } from '../identitySchema' + +describe('identityFormSchema', () => { + describe('email validation', () => { + it('should accept valid email', () => { + const result = identityFormSchema.safeParse({ + email: 'user@example.com', + displayName: 'Test User', + }) + + expect(result.success).toBe(true) + }) + + it('should reject invalid email format', () => { + const result = identityFormSchema.safeParse({ + email: 'invalid-email', + displayName: 'Test User', + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes('email'))).toBe(true) + } + }) + + it('should reject empty email', () => { + const result = identityFormSchema.safeParse({ + email: '', + displayName: 'Test User', + }) + + expect(result.success).toBe(false) + }) + + it('should normalize email to lowercase', () => { + const result = identityFormSchema.safeParse({ + email: 'User@EXAMPLE.COM', + displayName: 'Test User', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.email).toBe('user@example.com') + } + }) + }) + + describe('displayName validation', () => { + it('should accept valid displayName', () => { + const result = identityFormSchema.safeParse({ + email: 'user@example.com', + displayName: 'John Doe', + }) + + expect(result.success).toBe(true) + }) + + it('should reject empty displayName', () => { + const result = identityFormSchema.safeParse({ + email: 'user@example.com', + displayName: '', + }) + + expect(result.success).toBe(false) + }) + + it('should reject displayName longer than 255 characters', () => { + const result = identityFormSchema.safeParse({ + email: 'user@example.com', + displayName: 'a'.repeat(256), + }) + + expect(result.success).toBe(false) + }) + + it('should trim whitespace from displayName', () => { + const result = identityFormSchema.safeParse({ + email: 'user@example.com', + displayName: ' John Doe ', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.displayName).toBe('John Doe') + } + }) + }) + + describe('full form validation', () => { + it('should validate complete form', () => { + const result = identityFormSchema.safeParse({ + email: 'admin@example.com', + displayName: 'System Administrator', + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toEqual({ + email: 'admin@example.com', + displayName: 'System Administrator', + }) + } + }) + + it('should report multiple validation errors', () => { + const result = identityFormSchema.safeParse({ + email: 'invalid', + displayName: '', + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(1) + } + }) + }) +}) diff --git a/frontend/src/features/system/types/identitySchema.ts b/frontend/src/features/system/types/identitySchema.ts new file mode 100644 index 00000000..84f8a64f --- /dev/null +++ b/frontend/src/features/system/types/identitySchema.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' + +// Zod Schema for Identity Management +// Provides type-safe validation for identity data +// Syncs with backend: RegisterIdentity contract + +export const identityFormSchema = z.object({ + email: z + .string('이메일은 필수입니다') + .min(1, '이메일은 필수입니다') + .email('유효한 이메일 형식이 아닙니다') + .toLowerCase(), + + displayName: z + .string('표시명은 필수입니다') + .min(1, '표시명은 필수입니다') + .max(255, '표시명은 255자 이하여야 합니다') + .trim(), +}) + +export type IdentityFormData = z.infer + +// API Request Type (matches backend RegisterIdentityRequest) +export interface RegisterIdentityRequest { + email: string + displayName: string +} + +// API Response Type (matches backend RegisterIdentityResponse) +export interface RegisterIdentityResponse { + id: string + email: string + state: 'ACTIVE' | 'REQUIRES_MFA_SETUP' | 'MFA_CONFIGURED' | 'INACTIVE' +} + +// Domain Identity Type (backend: public.identity) +export interface Identity { + id: string + email: string + displayName: string + state: 'ACTIVE' | 'REQUIRES_MFA_SETUP' | 'MFA_CONFIGURED' | 'INACTIVE' | 'REVOKED' + mfaRequired: boolean + mfaEnforcedAt?: string + createdAt: string + updatedAt: string +} + +// List Response Type +export interface IdentityListResponse { + items: Identity[] + total: number + page: number + pageSize: number +} + +// Filter Options +export interface IdentityFilter { + search?: string + state?: Identity['state'] + mfaRequired?: boolean +}