Merge branch 'main' into feature/join-layout-20260808

This commit is contained in:
2026-08-08 17:12:22 +00:00
7 changed files with 491 additions and 66 deletions
+15
View File
@@ -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>;
+36 -4
View File
@@ -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('..')
+259 -50
View File
@@ -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">