feat(gateway): add admin user directory
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<UserRecord | null> {
|
||||
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<UserRecord> {
|
||||
if (usersByName.has(input.username)) {
|
||||
throw new Error('User already exists.');
|
||||
|
||||
@@ -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<UserRecord> {
|
||||
const password = await hasher.hash(input.password);
|
||||
const oauthType = input.oauth?.type ?? 'NONE';
|
||||
|
||||
@@ -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<UserRecord | null>;
|
||||
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
|
||||
findByEmail(email: string): Promise<UserRecord | null>;
|
||||
listForAdmin(input: { query?: string; limit: number; cursor?: string }): Promise<AdminUserListResult>;
|
||||
createUser(input: CreateUserInput): Promise<UserRecord>;
|
||||
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
||||
updatePassword(userId: string, password: string): Promise<void>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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('..')
|
||||
|
||||
@@ -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<AdminUser | null>(null);
|
||||
const userDirectoryQuery = ref('');
|
||||
const userDirectory = ref<AdminUserListItem[]>([]);
|
||||
const userDirectoryTotal = ref(0);
|
||||
const userDirectoryNextCursor = ref<string>();
|
||||
const userDirectoryLoading = ref(false);
|
||||
const userDirectoryError = ref('');
|
||||
const userWorkspaceSection = ref<UserWorkspaceSection>('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(() => {
|
||||
<div class="space-y-8">
|
||||
<section v-if="section === 'users'" class="grid min-w-0 items-start gap-6 xl:grid-cols-2">
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
|
||||
<h3 class="text-lg font-semibold">유저 관리</h3>
|
||||
<form class="space-y-3" @submit.prevent="lookupUser">
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<select
|
||||
v-model="userLookupMode"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="username">계정명</option>
|
||||
<option value="email">이메일</option>
|
||||
<option value="id">UUID</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="userLookupValue"
|
||||
type="text"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
||||
placeholder="검색 값 입력"
|
||||
/>
|
||||
<button
|
||||
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
||||
:disabled="userLoading"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">계정 디렉터리</h3>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
최근 가입 계정을 먼저 보여줍니다. 계정명·표시명·이메일·UUID 일부로 검색할 수
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
|
||||
<span class="text-xs text-zinc-400">총 {{ userDirectoryTotal }}개</span>
|
||||
</div>
|
||||
<form class="flex flex-col gap-2 md:flex-row" @submit.prevent="loadUserDirectory(false)">
|
||||
<input
|
||||
v-model="userDirectoryQuery"
|
||||
type="search"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
||||
placeholder="계정명, 표시명, 이메일 또는 UUID 검색"
|
||||
/>
|
||||
<button
|
||||
class="bg-blue-700 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold px-4 py-2 rounded"
|
||||
:disabled="userDirectoryLoading"
|
||||
>
|
||||
목록 검색
|
||||
</button>
|
||||
</form>
|
||||
<div v-if="userDirectoryError" class="text-xs text-red-400">{{ userDirectoryError }}</div>
|
||||
<div
|
||||
v-if="userDirectory.length"
|
||||
class="max-h-[28rem] overflow-y-auto rounded border border-zinc-800 bg-zinc-950"
|
||||
role="region"
|
||||
aria-label="계정 목록"
|
||||
>
|
||||
<button
|
||||
v-for="user in userDirectory"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
class="grid w-full gap-1 border-b border-zinc-800 px-4 py-3 text-left last:border-b-0 hover:bg-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-yellow-500 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto] md:items-center"
|
||||
:class="
|
||||
userResult?.id === user.id ? 'bg-zinc-900 ring-1 ring-inset ring-yellow-700' : ''
|
||||
"
|
||||
@click="selectDirectoryUser(user)"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold text-white">{{
|
||||
user.username
|
||||
}}</span>
|
||||
<span class="block truncate text-xs text-zinc-400">{{ user.displayName }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 text-xs text-zinc-500">
|
||||
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
|
||||
<span
|
||||
>{{ user.oauthType }} ·
|
||||
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="flex flex-wrap gap-1 md:justify-end">
|
||||
<span
|
||||
v-if="user.hasActiveSanction"
|
||||
class="rounded bg-red-950 px-2 py-1 text-[11px] text-red-300"
|
||||
>제재 중</span
|
||||
>
|
||||
<span
|
||||
v-if="user.deleteAfter"
|
||||
class="rounded bg-orange-950 px-2 py-1 text-[11px] text-orange-300"
|
||||
>탈퇴 예약</span
|
||||
>
|
||||
<span class="rounded bg-zinc-800 px-2 py-1 text-[11px] text-zinc-300">{{
|
||||
user.roles[0] || '역할 없음'
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!userDirectoryLoading"
|
||||
class="rounded border border-dashed border-zinc-700 p-6 text-center text-sm text-zinc-500"
|
||||
>
|
||||
조건에 맞는 계정이 없습니다.
|
||||
</div>
|
||||
<button
|
||||
v-if="userDirectoryNextCursor"
|
||||
type="button"
|
||||
class="w-full rounded border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
:disabled="userDirectoryLoading"
|
||||
@click="loadUserDirectory(true)"
|
||||
>
|
||||
더 보기
|
||||
</button>
|
||||
|
||||
<details class="rounded border border-zinc-800 bg-black/20 p-3">
|
||||
<summary class="cursor-pointer text-xs font-semibold text-zinc-400">
|
||||
정확한 값으로 직접 열기
|
||||
</summary>
|
||||
<form class="space-y-3" @submit.prevent="lookupUser">
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<select
|
||||
v-model="userLookupMode"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="username">계정명</option>
|
||||
<option value="email">이메일</option>
|
||||
<option value="id">UUID</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="userLookupValue"
|
||||
type="text"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
|
||||
placeholder="검색 값 입력"
|
||||
/>
|
||||
<button
|
||||
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
||||
:disabled="userLoading"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div v-if="userResult" class="bg-zinc-950 border border-zinc-800 rounded p-4 space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -1203,7 +1355,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3 xl:col-span-2">
|
||||
<div
|
||||
v-if="hasUser"
|
||||
class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3 xl:col-span-2"
|
||||
>
|
||||
<h4 class="text-base font-semibold">민감 조치 공통 사유</h4>
|
||||
<input
|
||||
v-model="userActionReason"
|
||||
@@ -1215,7 +1370,33 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">사유와 정화된 입력은 관리자 감사 원장에 기록됩니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<nav
|
||||
v-if="hasUser"
|
||||
class="grid gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-2 sm:grid-cols-2 xl:col-span-2 xl:grid-cols-4"
|
||||
aria-label="사용자 관리 기능"
|
||||
>
|
||||
<button
|
||||
v-for="item in userWorkspaceSections"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="rounded px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-500"
|
||||
:class="
|
||||
userWorkspaceSection === item.id
|
||||
? 'bg-yellow-600 text-black'
|
||||
: 'bg-zinc-950 text-zinc-300 hover:bg-zinc-800'
|
||||
"
|
||||
:aria-current="userWorkspaceSection === item.id ? 'page' : undefined"
|
||||
@click="userWorkspaceSection = item.id"
|
||||
>
|
||||
<span class="block text-sm font-semibold">{{ item.label }}</span>
|
||||
<span class="mt-1 block text-[11px] opacity-75">{{ item.description }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'account'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
||||
<span class="text-xs text-zinc-500"> ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} </span>
|
||||
@@ -1257,7 +1438,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'access'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<input
|
||||
@@ -1279,7 +1463,10 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'access'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2"
|
||||
>
|
||||
<h4 class="text-base font-semibold">특수 권한 부여</h4>
|
||||
<div class="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
|
||||
<select
|
||||
@@ -1341,11 +1528,14 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'access'"
|
||||
class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
||||
<div class="text-xs text-zinc-400">
|
||||
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서
|
||||
서버 범위와 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
|
||||
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
|
||||
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<select
|
||||
@@ -1396,7 +1586,8 @@ onMounted(() => {
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-semibold text-amber-200">
|
||||
{{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
|
||||
{{ grant.kind }} ·
|
||||
{{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!grant.revokedAt"
|
||||
@@ -1419,7 +1610,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'access'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
||||
<div class="text-xs text-zinc-500">
|
||||
기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다.
|
||||
@@ -1483,7 +1677,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'restrictions'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">유저 차단</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
@@ -1519,7 +1716,10 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ banStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'restrictions'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
|
||||
<div class="grid gap-2">
|
||||
<input
|
||||
@@ -1576,7 +1776,10 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ restrictionStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'restrictions'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2"
|
||||
>
|
||||
<h4 class="text-base font-semibold">프로필 아이콘 초기화</h4>
|
||||
<button
|
||||
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-4 py-2 rounded"
|
||||
@@ -1588,7 +1791,10 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ profileIconStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'lifecycle'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold text-red-400">관리자 탈퇴 예약</h4>
|
||||
<input
|
||||
v-model.number="deletionRetentionDays"
|
||||
@@ -1609,7 +1815,10 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'lifecycle'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">사용자 관리자 조치 이력</h4>
|
||||
<div v-if="!userHistory.length" class="text-xs text-zinc-500">기록이 없습니다.</div>
|
||||
<div v-else class="max-h-80 overflow-auto space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user