diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 4c2d3a35..d7b81de8 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -381,6 +381,14 @@ const zUserLookupInput = z message: 'id, username, or email must be provided.', }); +const zUserListInput = z + .object({ + query: z.string().trim().max(100).optional(), + limit: z.number().int().min(1).max(100).default(30), + cursor: z.string().uuid().optional(), + }) + .optional(); + const zServerRestriction = z.object({ blockedFeatures: z.array(z.string().min(1)).optional(), until: z.string().datetime().nullable().optional(), @@ -584,6 +592,13 @@ export const adminRouter = router({ getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({ enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled, })), + list: userAdminProcedure.input(zUserListInput).query(({ ctx, input }) => + ctx.users.listForAdmin({ + query: input?.query, + limit: input?.limit ?? 30, + cursor: input?.cursor, + }) + ), createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => { const gatewayCtx = ctx as GatewayApiContext; assertLocalAccountEnabled(gatewayCtx); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 77f81e40..30abd617 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -2,12 +2,26 @@ import { randomUUID } from 'node:crypto'; import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; import type { + AdminUserListItem, CreateUserInput, SpecialAccountAccessGrantRecord, UserIconRecord, UserRecord, UserRepository, } from './userRepository.js'; +import { hasActiveUserSanction } from './userRepository.js'; + +const toAdminUserListItem = (user: UserRecord): AdminUserListItem => ({ + id: user.id, + username: user.username, + displayName: user.displayName, + email: user.email, + oauthType: user.oauthType, + roles: [...user.roles], + hasActiveSanction: hasActiveUserSanction(user.sanctions), + deleteAfter: user.deleteAfter, + createdAt: user.createdAt, +}); // 유저 데이터 저장소를 메모리로 대체한 임시 구현. export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => { @@ -56,6 +70,31 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp async findByEmail(email: string): Promise { return usersByEmail.get(email.toLowerCase()) ?? null; }, + async listForAdmin(input) { + const query = input.query?.trim().toLocaleLowerCase() ?? ''; + const matching = [...usersByName.values()] + .filter((user) => { + if (!query) return true; + return [user.username, user.displayName, user.email ?? '', user.id].some((value) => + value.toLocaleLowerCase().includes(query) + ); + }) + .sort((left, right) => { + const byCreatedAt = right.createdAt.localeCompare(left.createdAt); + return byCreatedAt === 0 ? right.id.localeCompare(left.id) : byCreatedAt; + }); + const startIndex = input.cursor + ? Math.max(0, matching.findIndex((user) => user.id === input.cursor) + 1) + : 0; + const page = matching.slice(startIndex, startIndex + input.limit + 1); + const hasNextPage = page.length > input.limit; + const users = page.slice(0, input.limit); + return { + users: users.map(toAdminUserListItem), + total: matching.length, + nextCursor: hasNextPage ? users.at(-1)?.id : undefined, + }; + }, async createUser(input: CreateUserInput): Promise { if (usersByName.has(input.username)) { throw new Error('User already exists.'); diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 8e8228d1..aa7574eb 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -2,6 +2,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; import type { + AdminUserListItem, CreateUserInput, SpecialAccountAccessGrantRecord, UserIconRecord, @@ -10,6 +11,21 @@ import type { UserRepository, UserSanctions, } from './userRepository.js'; +import { hasActiveUserSanction } from './userRepository.js'; + +const toAdminUserListItem = (user: UserRecord): AdminUserListItem => { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + email: user.email, + oauthType: user.oauthType, + roles: user.roles, + hasActiveSanction: hasActiveUserSanction(user.sanctions), + deleteAfter: user.deleteAfter, + createdAt: user.createdAt, + }; +}; const readStringArray = (value: unknown): string[] => { if (!Array.isArray(value)) { @@ -195,6 +211,35 @@ export const createPostgresUserRepository = ( }); return row ? mapUser(row) : null; }, + async listForAdmin(input) { + const query = input.query?.trim(); + const where = query + ? { + OR: [ + { loginId: { contains: query, mode: 'insensitive' as const } }, + { displayName: { contains: query, mode: 'insensitive' as const } }, + { email: { contains: query, mode: 'insensitive' as const } }, + { id: { contains: query, mode: 'insensitive' as const } }, + ], + } + : undefined; + const [rows, total] = await Promise.all([ + prisma.appUser.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: input.limit + 1, + ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}), + }), + prisma.appUser.count({ where }), + ]); + const hasNextPage = rows.length > input.limit; + const page = rows.slice(0, input.limit); + return { + users: page.map(mapUser).map(toAdminUserListItem), + total, + nextCursor: hasNextPage ? page.at(-1)?.id : undefined, + }; + }, async createUser(input: CreateUserInput): Promise { const password = await hasher.hash(input.password); const oauthType = input.oauth?.type ?? 'NONE'; diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index fb8f8e96..a8076005 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -73,6 +73,24 @@ export interface PublicUser { createdAt: string; } +export interface AdminUserListItem { + id: string; + username: string; + displayName: string; + email?: string; + oauthType: 'NONE' | 'KAKAO'; + roles: string[]; + hasActiveSanction: boolean; + deleteAfter?: string; + createdAt: string; +} + +export interface AdminUserListResult { + users: AdminUserListItem[]; + total: number; + nextCursor?: string; +} + export interface UserSanctions { bannedUntil?: string; mutedUntil?: string; @@ -91,6 +109,19 @@ export interface UserServerRestriction { notes?: string; } +export const hasActiveUserSanction = (sanctions: UserSanctions, now = Date.now()): boolean => { + const hasActiveGlobalSanction = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil].some( + (value) => value !== undefined && new Date(value).getTime() > now + ); + if (hasActiveGlobalSanction || (sanctions.flags?.length ?? 0) > 0) { + return true; + } + return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) => { + if ((restriction.blockedFeatures?.length ?? 0) === 0) return false; + return restriction.until === undefined || new Date(restriction.until).getTime() > now; + }); +}; + export const toPublicUser = (user: UserRecord): PublicUser => ({ id: user.id, username: user.username, @@ -124,6 +155,7 @@ export interface UserRepository { findByDisplayName(displayName: string): Promise; findByOauthId(type: 'KAKAO', oauthId: string): Promise; findByEmail(email: string): Promise; + listForAdmin(input: { query?: string; limit: number; cursor?: string }): Promise; createUser(input: CreateUserInput): Promise; verifyPassword(user: UserRecord, password: string): Promise; updatePassword(userId: string, password: string): Promise; diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index f742cd7d..1d9bf6a1 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -846,6 +846,39 @@ describe('Gateway administrator account controls', () => { throw new Error('not used'); }; + it('lists accounts before exact lookup and supports partial search with cursor pagination', async () => { + const { caller, users } = await buildCaller(unusedCreateOperation); + await users.createUser({ + username: 'alpha-user', + password: 'secretpass', + displayName: 'Pilot Alpha', + }); + await users.createUser({ + username: 'kakao-user', + password: 'secretpass', + displayName: 'Kakao Member', + oauth: { + type: 'KAKAO', + id: 'kakao-directory-id', + email: 'pilot@example.test', + info: {}, + }, + }); + + const search = await caller.admin.users.list({ query: 'pilot', limit: 30 }); + expect(search.total).toBe(2); + expect(search.users.map((user) => user.username).sort()).toEqual(['alpha-user', 'kakao-user']); + expect(search.users[0]).not.toHaveProperty('oauthId'); + + const firstPage = await caller.admin.users.list({ limit: 1 }); + expect(firstPage.total).toBe(3); + expect(firstPage.users).toHaveLength(1); + expect(firstPage.nextCursor).toBeTruthy(); + const secondPage = await caller.admin.users.list({ limit: 1, cursor: firstPage.nextCursor }); + expect(secondPage.users).toHaveLength(1); + expect(secondPage.users[0]?.id).not.toBe(firstPage.users[0]?.id); + }); + it('records sanitized STARTED and SUCCEEDED events and exposes target history', async () => { const harness = await buildCaller(unusedCreateOperation); const target = await harness.users.createUser({ @@ -954,10 +987,9 @@ describe('Gateway administrator account controls', () => { }) ).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' }); expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' }); - expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([ - 'admin.users.grantSpecialAccess', - 'admin.users.revokeSpecialAccess', - ]); + expect( + harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action) + ).toEqual(['admin.users.grantSpecialAccess', 'admin.users.revokeSpecialAccess']); }); it('requires recovery access to expire within 90 days', async () => { diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index d2b05bd1..ec9be92a 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -68,6 +68,42 @@ const installFixture = async (page: Page) => { } if (operation === 'admin.audit.list') return response(auditHistory); if (operation === 'admin.users.getLocalAccountStatus') return response({ enabled: false }); + if (operation === 'admin.users.list') { + return response({ + total: 3, + users: [ + { + id: 'target-user', + username: 'target', + displayName: '대상 사용자', + email: 'target@example.test', + oauthType: 'NONE', + roles: ['user'], + hasActiveSanction: false, + deleteAfter, + createdAt: '2026-07-20T00:00:00.000Z', + }, + { + id: 'viewer-user', + username: 'viewer', + displayName: '조회 사용자', + oauthType: 'KAKAO', + roles: ['user'], + hasActiveSanction: true, + createdAt: '2026-07-19T00:00:00.000Z', + }, + { + id: 'admin-user', + username: 'admin', + displayName: '관리자', + oauthType: 'NONE', + roles: ['superuser'], + hasActiveSanction: false, + createdAt: '2026-07-18T00:00:00.000Z', + }, + ], + }); + } if (operation === 'admin.system.getNotice') return response({ notice: '' }); if (operation === 'admin.profiles.list') return response([]); if (operation === 'admin.profiles.listScenarios') return response([]); @@ -167,20 +203,16 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', const mutations = await installFixture(page); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/users'); - await page.getByPlaceholder('검색 값 입력').fill('target'); - await page.getByRole('button', { name: '조회', exact: true }).click(); + await expect(page.getByRole('region', { name: '계정 목록' })).toBeVisible(); + await expect(page.getByText('총 3개')).toBeVisible(); + await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible(); + await expect(page.getByRole('navigation', { name: '사용자 관리 기능' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden(); + await page.getByRole('button', { name: /접근 · 권한/ }).click(); await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible(); - await expect(page.getByText('SUCCEEDED · admin.users.updateSanctions').first()).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true }); - const deletionButton = page.getByRole('button', { name: '보존 기간 후 탈퇴 예약', exact: true }); - const baseDeleteColor = await deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor); - await deletionButton.hover(); - await expect - .poll(() => deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor)) - .not.toBe(baseDeleteColor); - await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true }); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00'); await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che'); @@ -195,8 +227,16 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00'); await page.getByRole('button', { name: '유예 연장', exact: true }).click(); await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible(); - await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); + await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); + await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); + const deletionButton = page.getByRole('button', { name: '보존 기간 후 탈퇴 예약', exact: true }); + const baseDeleteColor = await deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor); + await deletionButton.hover(); + await expect + .poll(() => deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor)) + .not.toBe(baseDeleteColor); + await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true }); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수'); await page.getByLabel('탈퇴 전 보존 일수').fill('30'); await deletionButton.click(); @@ -205,12 +245,25 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true); expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); + await page.setViewportSize({ width: 390, height: 844 }); + const userDirectoryGeometry = await page.getByRole('region', { name: '계정 목록' }).evaluate((directory) => { + const rect = directory.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: window.innerWidth }; + }); + expect(userDirectoryGeometry.left).toBeGreaterThanOrEqual(0); + expect(userDirectoryGeometry.right).toBeLessThanOrEqual(userDirectoryGeometry.viewportWidth); + await writeFile( + testInfo.outputPath('gateway-admin-user-directory-mobile-geometry.json'), + JSON.stringify(userDirectoryGeometry) + ); + await page.screenshot({ path: testInfo.outputPath('gateway-admin-user-directory-mobile.png'), fullPage: true }); + + await page.getByRole('button', { name: '관리자 메뉴' }).click(); await page.getByRole('link', { name: '감사 로그' }).click(); await expect(page).toHaveURL(/\/gateway\/admin\/audit$/); await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible(); await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); - await page.setViewportSize({ width: 390, height: 844 }); const geometry = await page .getByRole('heading', { name: '전체 관리자 감사 원장' }) .locator('..') diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 22f1f610..f8f13fae 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -87,6 +87,20 @@ type AdminUser = { createdAt: string; }; +type AdminUserListItem = { + id: string; + username: string; + displayName: string; + email?: string; + oauthType: 'NONE' | 'KAKAO'; + roles: string[]; + hasActiveSanction: boolean; + deleteAfter?: string; + createdAt: string; +}; + +type UserWorkspaceSection = 'account' | 'access' | 'restrictions' | 'lifecycle'; + type AdminCapability = { permission: string; label: string; @@ -199,6 +213,13 @@ type AdminClient = { }; }; users: { + list: { + query: (input?: { query?: string; limit?: number; cursor?: string }) => Promise<{ + users: AdminUserListItem[]; + total: number; + nextCursor?: string; + }>; + }; getLocalAccountStatus: { query: () => Promise<{ enabled: boolean }>; }; @@ -400,6 +421,20 @@ const userLookupValue = ref(''); const userLoading = ref(false); const userError = ref(''); const userResult = ref(null); +const userDirectoryQuery = ref(''); +const userDirectory = ref([]); +const userDirectoryTotal = ref(0); +const userDirectoryNextCursor = ref(); +const userDirectoryLoading = ref(false); +const userDirectoryError = ref(''); +const userWorkspaceSection = ref('account'); + +const userWorkspaceSections: Array<{ id: UserWorkspaceSection; label: string; description: string }> = [ + { id: 'account', label: '계정', description: '기본 정보와 로컬 계정 생성' }, + { id: 'access', label: '접근 · 권한', description: '비밀번호, 운영 권한, Kakao 접근' }, + { id: 'restrictions', label: '보안 · 제재', description: '차단, 서버 제한, 아이콘 초기화' }, + { id: 'lifecycle', label: '탈퇴 · 이력', description: '탈퇴 예약과 관리자 조치 이력' }, +]; const localAccountEnabled = ref(false); const localAccountStatus = ref(''); @@ -740,6 +775,31 @@ const lookupUser = async () => { } }; +const loadUserDirectory = async (append = false) => { + userDirectoryLoading.value = true; + userDirectoryError.value = ''; + try { + const result = await adminClient.users.list.query({ + query: userDirectoryQuery.value.trim() || undefined, + limit: 30, + cursor: append ? userDirectoryNextCursor.value : undefined, + }); + userDirectory.value = append ? [...userDirectory.value, ...result.users] : result.users; + userDirectoryTotal.value = result.total; + userDirectoryNextCursor.value = result.nextCursor; + } catch { + userDirectoryError.value = '계정 목록을 불러오지 못했습니다.'; + } finally { + userDirectoryLoading.value = false; + } +}; + +const selectDirectoryUser = async (user: AdminUserListItem) => { + userLookupMode.value = 'id'; + userLookupValue.value = user.id; + await lookupUser(); +}; + const requireUserActionReason = (): string | null => { const reason = userActionReason.value.trim(); if (reason.length < 3) { @@ -806,7 +866,7 @@ const updateKakaoGrace = async (clear = false) => { const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id }); kakaoPolicies.value = grace.profiles; specialAccessGrants.value = grace.specialAccessGrants; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch { kakaoGraceStatus.value = 'OAuth 유예 변경 실패'; } @@ -833,7 +893,7 @@ const grantSpecialAccess = async () => { kakaoPolicies.value = policy.profiles; specialAccessGrants.value = policy.specialAccessGrants; specialAccessStatus.value = '특수 접근 자격을 부여했습니다.'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch { specialAccessStatus.value = '특수 접근 자격 부여에 실패했습니다.'; } @@ -849,7 +909,7 @@ const revokeSpecialAccess = async (grantId: string) => { kakaoPolicies.value = policy.profiles; specialAccessGrants.value = policy.specialAccessGrants; specialAccessStatus.value = '특수 접근 자격을 해제했습니다.'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch { specialAccessStatus.value = '특수 접근 자격 해제에 실패했습니다.'; } @@ -872,7 +932,7 @@ const resetUserPassword = async () => { passwordResult.value = result.password; passwordStatus.value = '초기화 완료'; passwordInput.value = ''; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { passwordStatus.value = '초기화 실패'; } @@ -902,7 +962,7 @@ const updateUserRoles = async () => { }); userResult.value = { ...userResult.value, roles: result.roles }; rolesStatus.value = '권한 업데이트 완료'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { rolesStatus.value = '권한 업데이트 실패'; } @@ -927,7 +987,7 @@ const applyBan = async () => { }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; banStatus.value = '차단 설정 완료'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { banStatus.value = '차단 설정 실패'; } @@ -947,7 +1007,7 @@ const clearBan = async () => { }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; banStatus.value = '차단 해제 완료'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { banStatus.value = '차단 해제 실패'; } @@ -971,7 +1031,7 @@ const resetProfileIcon = async () => { profileIconStatus.value = result.flushPublished ? '아이콘 초기화 요청 완료' : '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { profileIconStatus.value = '아이콘 초기화 실패'; } @@ -1006,7 +1066,7 @@ const applyRestriction = async () => { }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; restrictionStatus.value = '서버 제재 적용 완료'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { restrictionStatus.value = '서버 제재 적용 실패'; } @@ -1031,7 +1091,7 @@ const clearRestriction = async () => { }); userResult.value = { ...userResult.value, sanctions: result.sanctions }; restrictionStatus.value = '서버 제재 해제 완료'; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { restrictionStatus.value = '서버 제재 해제 실패'; } @@ -1057,7 +1117,7 @@ const scheduleDeleteUser = async () => { }); userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter }; forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`; - await refreshUserHistory(); + await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { forceDeleteStatus.value = '탈퇴 예약 실패'; } @@ -1093,7 +1153,7 @@ const createLocalAccount = async () => { }; userLookupMode.value = 'username'; userLookupValue.value = result.user.username; - await lookupUser(); + await Promise.all([lookupUser(), loadUserDirectory()]); } catch (error) { localAccountStatus.value = '로컬 계정 생성 실패'; } finally { @@ -1106,6 +1166,7 @@ onMounted(() => { void loadCapabilities(); } if (props.section === 'users') { + void loadUserDirectory(); void loadLocalAccountStatus(); } if (props.section === 'system') { @@ -1148,32 +1209,123 @@ onMounted(() => {
-

유저 관리

-
-
- - - +
+
+

계정 디렉터리

+

+ 최근 가입 계정을 먼저 보여줍니다. 계정명·표시명·이메일·UUID 일부로 검색할 수 + 있습니다. +

-
{{ userError }}
+ 총 {{ userDirectoryTotal }}개 +
+ + + +
{{ userDirectoryError }}
+
+ +
+
+ 조건에 맞는 계정이 없습니다. +
+ + +
+ + 정확한 값으로 직접 열기 + +
+
+ + + +
+
{{ userError }}
+
+
@@ -1203,7 +1355,10 @@ onMounted(() => {
-
+

민감 조치 공통 사유

{
사유와 정화된 입력은 관리자 감사 원장에 기록됩니다.
-
+ + +

로컬 계정 생성

ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} @@ -1257,7 +1438,10 @@ onMounted(() => {
-
+

비밀번호 리셋

{
{{ passwordStatus }}
-
+

특수 권한 부여

{ >
- {{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }} + {{ grant.kind }} · + {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
-
+

Kakao 인증 유예

기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다. @@ -1483,7 +1677,10 @@ onMounted(() => {
-
+

유저 차단

{
{{ banStatus }}
-
+

서버별 기능 제재

{
{{ restrictionStatus }}
-
+

프로필 아이콘 초기화

-
+

관리자 탈퇴 예약

{
{{ forceDeleteStatus }}
-
+

사용자 관리자 조치 이력

기록이 없습니다.