feat(gateway): add accountable admin controls
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||
let deleteAfter: string | null = null;
|
||||
let graceUntil: string | null = null;
|
||||
const auditHistory = [
|
||||
{
|
||||
id: 'audit-1',
|
||||
correlationId: 'correlation-1',
|
||||
actorUsername: 'admin',
|
||||
action: 'admin.users.updateSanctions',
|
||||
outcome: 'SUCCEEDED',
|
||||
reason: '기존 제재 사유',
|
||||
summary: {},
|
||||
createdAt: '2026-08-06T01:00:00.000Z',
|
||||
},
|
||||
];
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'playwright-admin-session');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const body = route.request().postDataJSON() as unknown;
|
||||
const results = operations.map((operation) => {
|
||||
if (route.request().method() === 'POST') mutations.push({ operation, body });
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
id: 'admin-user',
|
||||
username: 'admin',
|
||||
displayName: '관리자',
|
||||
roles: ['superuser'],
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
if (operation === 'admin.capabilities.list') {
|
||||
return response([
|
||||
{
|
||||
permission: 'admin.users.manage',
|
||||
label: '사용자·제재 관리',
|
||||
description: '계정 복구, 제재, OAuth 유예와 예약 탈퇴를 관리합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'GLOBAL',
|
||||
},
|
||||
{
|
||||
permission: 'admin.profiles.manage',
|
||||
label: 'Profile 운영',
|
||||
description: '지정 profile을 관리합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'PROFILE',
|
||||
},
|
||||
{
|
||||
permission: 'admin.audit.read',
|
||||
label: '관리자 감사 조회',
|
||||
description: 'Gateway 관리자 변경 이력을 조회합니다.',
|
||||
risk: 'HIGH',
|
||||
scope: 'GLOBAL',
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (operation === 'admin.audit.list') return response(auditHistory);
|
||||
if (operation === 'admin.users.getLocalAccountStatus') return response({ enabled: false });
|
||||
if (operation === 'admin.system.getNotice') return response({ notice: '' });
|
||||
if (operation === 'admin.profiles.list') return response([]);
|
||||
if (operation === 'admin.profiles.listScenarios') return response([]);
|
||||
if (operation === 'admin.users.lookup') {
|
||||
return response({
|
||||
id: 'target-user',
|
||||
username: 'target',
|
||||
displayName: '대상 사용자',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
oauthType: 'NONE',
|
||||
kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z',
|
||||
kakaoGraceUntil: graceUntil,
|
||||
deleteAfter,
|
||||
createdAt: '2026-07-20T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
if (operation === 'admin.users.getKakaoGracePolicies') {
|
||||
return response({
|
||||
kakaoVerified: false,
|
||||
kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z',
|
||||
kakaoGraceUntil: graceUntil,
|
||||
profiles: [
|
||||
{
|
||||
profileName: 'che:default',
|
||||
requiresKakaoVerification: true,
|
||||
kakaoVerified: false,
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: false,
|
||||
graceEndsAt: graceUntil ?? '2026-08-10T00:00:00.000Z',
|
||||
generalCreationGraceDays: 0,
|
||||
accessGraceDays: 7,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'admin.users.listHistory') return response(auditHistory);
|
||||
if (operation === 'admin.users.updateKakaoGrace') {
|
||||
graceUntil = '2026-08-20T00:00:00.000Z';
|
||||
auditHistory.unshift({
|
||||
...auditHistory[0],
|
||||
id: 'audit-2',
|
||||
action: 'admin.users.updateKakaoGrace',
|
||||
reason: '본인 확인 처리 중',
|
||||
});
|
||||
return response({ kakaoGraceUntil: graceUntil });
|
||||
}
|
||||
if (operation === 'admin.users.scheduleDeletion') {
|
||||
deleteAfter = '2026-09-05T00:00:00.000Z';
|
||||
auditHistory.unshift({
|
||||
...auditHistory[0],
|
||||
id: 'audit-3',
|
||||
action: 'admin.users.scheduleDeletion',
|
||||
reason: '탈퇴 요청 접수',
|
||||
});
|
||||
return response({ ok: true, deleteAfter });
|
||||
}
|
||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
return mutations;
|
||||
};
|
||||
|
||||
test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => {
|
||||
const mutations = await installFixture(page);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto('admin');
|
||||
await page.getByPlaceholder('검색 값 입력').fill('target');
|
||||
await page.getByRole('button', { name: '조회', exact: true }).click();
|
||||
|
||||
await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible();
|
||||
await expect(page.getByText('SUCCEEDED · admin.users.updateSanctions').first()).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).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.locator('input[type="datetime-local"]').nth(0).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.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수');
|
||||
await page.getByLabel('탈퇴 전 보존 일수').fill('30');
|
||||
await deletionButton.click();
|
||||
await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible();
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const geometry = await page
|
||||
.getByRole('heading', { name: '전체 관리자 감사 원장' })
|
||||
.locator('..')
|
||||
.evaluate((panel) => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: window.innerWidth };
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth);
|
||||
await writeFile(testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'), JSON.stringify(geometry));
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-mobile.png'), fullPage: true });
|
||||
});
|
||||
@@ -89,6 +89,17 @@ const installFixture = async (
|
||||
if (operation === 'admin.users.getLocalAccountStatus') {
|
||||
return response({ enabled: true });
|
||||
}
|
||||
if (operation === 'admin.capabilities.list') {
|
||||
return response([
|
||||
{
|
||||
permission: 'admin.users.manage',
|
||||
label: '사용자·제재 관리',
|
||||
description: '계정 복구와 제재를 관리합니다.',
|
||||
risk: 'CRITICAL',
|
||||
scope: 'GLOBAL',
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (operation === 'admin.profiles.listScenarios') {
|
||||
return response([
|
||||
{
|
||||
|
||||
@@ -40,6 +40,9 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
if (operation === 'admin.users.getLocalAccountStatus') {
|
||||
return response({ enabled: true });
|
||||
}
|
||||
if (operation === 'admin.capabilities.list') {
|
||||
return response([]);
|
||||
}
|
||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
testMatch: [
|
||||
'server-operations.spec.ts',
|
||||
'admin-runtime-actions.spec.ts',
|
||||
'admin-account-controls.spec.ts',
|
||||
'lobby-admin-navigation.spec.ts',
|
||||
'lobby-game-auth.spec.ts',
|
||||
'logout.spec.ts',
|
||||
|
||||
@@ -48,10 +48,48 @@ type AdminUser = {
|
||||
oauthType: string;
|
||||
oauthId?: string;
|
||||
email?: string;
|
||||
kakaoVerifiedAt?: string;
|
||||
kakaoGraceStartedAt: string;
|
||||
kakaoGraceUntil?: string;
|
||||
profileIconResetAt?: string;
|
||||
deleteAfter?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AdminCapability = {
|
||||
permission: string;
|
||||
label: string;
|
||||
description: string;
|
||||
risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
scope: 'GLOBAL' | 'PROFILE';
|
||||
};
|
||||
|
||||
type AdminAuditEvent = {
|
||||
id: string;
|
||||
correlationId: string;
|
||||
actorUsername: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
profileName?: string;
|
||||
action: string;
|
||||
outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED';
|
||||
reason?: string;
|
||||
summary: Record<string, unknown>;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type KakaoGracePolicy = {
|
||||
profileName: string;
|
||||
requiresKakaoVerification: boolean;
|
||||
kakaoVerified: boolean;
|
||||
accessAllowed: boolean;
|
||||
canCreateGeneral: boolean;
|
||||
graceEndsAt: string | null;
|
||||
generalCreationGraceDays: number;
|
||||
accessGraceDays: number;
|
||||
};
|
||||
|
||||
type AdminPublicUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -140,6 +178,12 @@ type AdminAction =
|
||||
'RESUME' | 'PAUSE' | 'STOP' | 'ACCELERATE' | 'DELAY' | 'RESET_NOW' | 'RESET_SCHEDULED' | 'OPEN_SURVEY' | 'SHUTDOWN';
|
||||
|
||||
type AdminClient = {
|
||||
capabilities: {
|
||||
list: { query: () => Promise<AdminCapability[]> };
|
||||
};
|
||||
audit: {
|
||||
list: { query: (input?: { limit?: number }) => Promise<AdminAuditEvent[]> };
|
||||
};
|
||||
system: {
|
||||
getNotice: {
|
||||
query: () => Promise<{ notice: string }>;
|
||||
@@ -162,20 +206,38 @@ type AdminClient = {
|
||||
lookup: {
|
||||
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
|
||||
};
|
||||
getKakaoGracePolicies: {
|
||||
query: (input: { userId: string }) => Promise<{
|
||||
kakaoVerified: boolean;
|
||||
kakaoGraceStartedAt: string;
|
||||
kakaoGraceUntil: string | null;
|
||||
profiles: KakaoGracePolicy[];
|
||||
}>;
|
||||
};
|
||||
updateKakaoGrace: {
|
||||
mutate: (input: { userId: string; until: string | null; reason: string }) => Promise<{
|
||||
kakaoGraceUntil: string | null;
|
||||
}>;
|
||||
};
|
||||
listHistory: {
|
||||
query: (input: { userId: string; limit?: number }) => Promise<AdminAuditEvent[]>;
|
||||
};
|
||||
resetPassword: {
|
||||
mutate: (input: { userId: string; newPassword?: string }) => Promise<{ password: string }>;
|
||||
mutate: (input: { userId: string; newPassword?: string; reason: string }) => Promise<{ password: string }>;
|
||||
};
|
||||
updateRoles: {
|
||||
mutate: (input: {
|
||||
userId: string;
|
||||
roles: string[];
|
||||
mode?: 'set' | 'grant' | 'revoke';
|
||||
reason: string;
|
||||
}) => Promise<{ roles: string[] }>;
|
||||
};
|
||||
updateSanctions: {
|
||||
mutate: (input: {
|
||||
userId: string;
|
||||
patch: AdminSanctionsPatch;
|
||||
reason: string;
|
||||
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
||||
};
|
||||
setServerRestriction: {
|
||||
@@ -188,16 +250,20 @@ type AdminClient = {
|
||||
reason?: string | null;
|
||||
notes?: string | null;
|
||||
} | null;
|
||||
reason: string;
|
||||
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
||||
};
|
||||
resetProfileIcon: {
|
||||
mutate: (input: { userId: string }) => Promise<{
|
||||
mutate: (input: { userId: string; reason: string }) => Promise<{
|
||||
profileIconResetAt: string;
|
||||
flushPublished: boolean;
|
||||
}>;
|
||||
};
|
||||
forceDelete: {
|
||||
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
|
||||
scheduleDeletion: {
|
||||
mutate: (input: { userId: string; retentionDays: number; reason: string }) => Promise<{
|
||||
ok: boolean;
|
||||
deleteAfter: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
profiles: {
|
||||
@@ -216,7 +282,10 @@ type AdminClient = {
|
||||
inGameNotice?: string | null;
|
||||
profileImageUrl?: string | null;
|
||||
nextSeasonIdx?: number | null;
|
||||
localAccountAccessGraceDays?: number | null;
|
||||
localAccountGeneralCreationGraceDays?: number | null;
|
||||
};
|
||||
reason: string;
|
||||
}) => Promise<AdminProfile | null>;
|
||||
};
|
||||
install: {
|
||||
@@ -292,6 +361,9 @@ const profileEdits = ref<
|
||||
inGameNotice: string;
|
||||
profileImageUrl: string;
|
||||
nextSeasonIdx: string;
|
||||
localAccountAccessGraceDays: string;
|
||||
localAccountGeneralCreationGraceDays: string;
|
||||
reason: string;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
@@ -371,6 +443,10 @@ const passwordStatus = ref('');
|
||||
const rolesInput = ref('');
|
||||
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
|
||||
const rolesStatus = ref('');
|
||||
const capabilities = ref<AdminCapability[]>([]);
|
||||
const selectedCapability = ref('');
|
||||
const capabilityProfile = ref('');
|
||||
const userActionReason = ref('');
|
||||
|
||||
const banUntil = ref('');
|
||||
const banReason = ref('');
|
||||
@@ -386,6 +462,13 @@ const restrictionNotes = ref('');
|
||||
const restrictionStatus = ref('');
|
||||
|
||||
const forceDeleteStatus = ref('');
|
||||
const deletionRetentionDays = ref(30);
|
||||
const kakaoGraceUntil = ref('');
|
||||
const kakaoGraceStatus = ref('');
|
||||
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
|
||||
const userHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditStatus = ref('');
|
||||
|
||||
const hasUser = computed(() => Boolean(userResult.value));
|
||||
|
||||
@@ -441,6 +524,15 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
|
||||
typeof meta.nextSeasonIdx === 'number' && Number.isFinite(meta.nextSeasonIdx)
|
||||
? String(Math.floor(meta.nextSeasonIdx))
|
||||
: '',
|
||||
localAccountAccessGraceDays:
|
||||
typeof meta.localAccountAccessGraceDays === 'number'
|
||||
? String(Math.floor(meta.localAccountAccessGraceDays))
|
||||
: '',
|
||||
localAccountGeneralCreationGraceDays:
|
||||
typeof meta.localAccountGeneralCreationGraceDays === 'number'
|
||||
? String(Math.floor(meta.localAccountGeneralCreationGraceDays))
|
||||
: '',
|
||||
reason: '',
|
||||
};
|
||||
}
|
||||
if (!profileActions.value[profile.profileName]) {
|
||||
@@ -711,17 +803,38 @@ const updateProfileMeta = async (profileName: string) => {
|
||||
};
|
||||
return;
|
||||
}
|
||||
const readGraceDays = (value: string): number | null => {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 365 ? parsed : Number.NaN;
|
||||
};
|
||||
const accessGraceDays = readGraceDays(edit.localAccountAccessGraceDays);
|
||||
const creationGraceDays = readGraceDays(edit.localAccountGeneralCreationGraceDays);
|
||||
if (Number.isNaN(accessGraceDays) || Number.isNaN(creationGraceDays)) {
|
||||
profileActionStatus.value = {
|
||||
...profileActionStatus.value,
|
||||
[profileName]: 'Kakao 유예일은 0~365 사이 정수여야 합니다.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (edit.reason.trim().length < 3) {
|
||||
profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' };
|
||||
return;
|
||||
}
|
||||
const patch = {
|
||||
korName: edit.korName.trim() || null,
|
||||
color: edit.color.trim() || null,
|
||||
inGameNotice: edit.inGameNotice.trim() || null,
|
||||
profileImageUrl: edit.profileImageUrl.trim() || null,
|
||||
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
|
||||
localAccountAccessGraceDays: accessGraceDays,
|
||||
localAccountGeneralCreationGraceDays: creationGraceDays,
|
||||
};
|
||||
try {
|
||||
const updated = await adminClient.profiles.updateMeta.mutate({
|
||||
profileName,
|
||||
patch,
|
||||
reason: edit.reason.trim(),
|
||||
});
|
||||
profileActionStatus.value = {
|
||||
...profileActionStatus.value,
|
||||
@@ -890,6 +1003,13 @@ const lookupUser = async () => {
|
||||
return;
|
||||
}
|
||||
userResult.value = result;
|
||||
const [grace, history] = await Promise.all([
|
||||
adminClient.users.getKakaoGracePolicies.query({ userId: result.id }),
|
||||
adminClient.users.listHistory.query({ userId: result.id, limit: 50 }),
|
||||
]);
|
||||
kakaoPolicies.value = grace.profiles;
|
||||
kakaoGraceUntil.value = grace.kakaoGraceUntil ? toLocalInputValue(grace.kakaoGraceUntil) : '';
|
||||
userHistory.value = history;
|
||||
} catch (error) {
|
||||
userError.value = '조회 실패';
|
||||
} finally {
|
||||
@@ -897,20 +1017,95 @@ const lookupUser = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const requireUserActionReason = (): string | null => {
|
||||
const reason = userActionReason.value.trim();
|
||||
if (reason.length < 3) {
|
||||
userError.value = '민감한 관리자 조치에는 3자 이상의 사유가 필요합니다.';
|
||||
return null;
|
||||
}
|
||||
return reason;
|
||||
};
|
||||
|
||||
const loadGlobalAudit = async () => {
|
||||
if (!capabilities.value.some((entry) => entry.permission === 'admin.audit.read')) return;
|
||||
try {
|
||||
globalAuditHistory.value = await adminClient.audit.list.query({ limit: 100 });
|
||||
globalAuditStatus.value = '';
|
||||
} catch {
|
||||
globalAuditStatus.value = '감사 원장을 불러오지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUserHistory = async () => {
|
||||
if (!userResult.value) return;
|
||||
userHistory.value = await adminClient.users.listHistory.query({ userId: userResult.value.id, limit: 50 });
|
||||
await loadGlobalAudit();
|
||||
};
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
try {
|
||||
capabilities.value = await adminClient.capabilities.list.query();
|
||||
selectedCapability.value = capabilities.value[0]?.permission ?? '';
|
||||
await loadGlobalAudit();
|
||||
} catch {
|
||||
capabilities.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const applyCapabilitySelection = () => {
|
||||
const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value);
|
||||
if (!capability) return;
|
||||
if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) {
|
||||
rolesStatus.value = 'Profile 범위를 입력하세요.';
|
||||
return;
|
||||
}
|
||||
rolesInput.value =
|
||||
capability.scope === 'PROFILE'
|
||||
? `${capability.permission}:${capabilityProfile.value.trim()}`
|
||||
: capability.permission;
|
||||
};
|
||||
|
||||
const updateKakaoGrace = async (clear = false) => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
try {
|
||||
const result = await adminClient.users.updateKakaoGrace.mutate({
|
||||
userId: userResult.value.id,
|
||||
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(),
|
||||
reason,
|
||||
});
|
||||
userResult.value = {
|
||||
...userResult.value,
|
||||
kakaoGraceUntil: result.kakaoGraceUntil ?? undefined,
|
||||
};
|
||||
kakaoGraceStatus.value = result.kakaoGraceUntil ? 'OAuth 유예 연장 완료' : '개별 유예 해제 완료';
|
||||
const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||
kakaoPolicies.value = grace.profiles;
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
kakaoGraceStatus.value = 'OAuth 유예 변경 실패';
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserPassword = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
passwordStatus.value = '';
|
||||
passwordResult.value = '';
|
||||
try {
|
||||
const result = await adminClient.users.resetPassword.mutate({
|
||||
userId: userResult.value.id,
|
||||
newPassword: passwordInput.value.trim() || undefined,
|
||||
reason,
|
||||
});
|
||||
passwordResult.value = result.password;
|
||||
passwordStatus.value = '초기화 완료';
|
||||
passwordInput.value = '';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
passwordStatus.value = '초기화 실패';
|
||||
}
|
||||
@@ -920,6 +1115,8 @@ const updateUserRoles = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
const roles = rolesInput.value
|
||||
.split(',')
|
||||
.map((role) => role.trim())
|
||||
@@ -934,9 +1131,11 @@ const updateUserRoles = async () => {
|
||||
userId: userResult.value.id,
|
||||
roles,
|
||||
mode: rolesMode.value,
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, roles: result.roles };
|
||||
rolesStatus.value = '권한 업데이트 완료';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
rolesStatus.value = '권한 업데이트 실패';
|
||||
}
|
||||
@@ -946,6 +1145,8 @@ const applyBan = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
|
||||
const patch = {
|
||||
bannedUntil: until,
|
||||
@@ -955,9 +1156,11 @@ const applyBan = async () => {
|
||||
const result = await adminClient.users.updateSanctions.mutate({
|
||||
userId: userResult.value.id,
|
||||
patch,
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||
banStatus.value = '차단 설정 완료';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
banStatus.value = '차단 설정 실패';
|
||||
}
|
||||
@@ -967,13 +1170,17 @@ const clearBan = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
try {
|
||||
const result = await adminClient.users.updateSanctions.mutate({
|
||||
userId: userResult.value.id,
|
||||
patch: { bannedUntil: null },
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||
banStatus.value = '차단 해제 완료';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
banStatus.value = '차단 해제 실패';
|
||||
}
|
||||
@@ -983,9 +1190,12 @@ const resetProfileIcon = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
try {
|
||||
const result = await adminClient.users.resetProfileIcon.mutate({
|
||||
userId: userResult.value.id,
|
||||
reason,
|
||||
});
|
||||
userResult.value = {
|
||||
...userResult.value,
|
||||
@@ -994,6 +1204,7 @@ const resetProfileIcon = async () => {
|
||||
profileIconStatus.value = result.flushPublished
|
||||
? '아이콘 초기화 요청 완료'
|
||||
: '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
profileIconStatus.value = '아이콘 초기화 실패';
|
||||
}
|
||||
@@ -1003,6 +1214,8 @@ const applyRestriction = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
if (!restrictionProfile.value.trim()) {
|
||||
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
||||
return;
|
||||
@@ -1022,9 +1235,11 @@ const applyRestriction = async () => {
|
||||
userId: userResult.value.id,
|
||||
profile: restrictionProfile.value.trim(),
|
||||
restriction,
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||
restrictionStatus.value = '서버 제재 적용 완료';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
restrictionStatus.value = '서버 제재 적용 실패';
|
||||
}
|
||||
@@ -1034,6 +1249,8 @@ const clearRestriction = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
if (!restrictionProfile.value.trim()) {
|
||||
restrictionStatus.value = '서버 프로필명을 입력하세요.';
|
||||
return;
|
||||
@@ -1043,30 +1260,39 @@ const clearRestriction = async () => {
|
||||
userId: userResult.value.id,
|
||||
profile: restrictionProfile.value.trim(),
|
||||
restriction: null,
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, sanctions: result.sanctions };
|
||||
restrictionStatus.value = '서버 제재 해제 완료';
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
restrictionStatus.value = '서버 제재 해제 실패';
|
||||
}
|
||||
};
|
||||
|
||||
const forceDeleteUser = async () => {
|
||||
const scheduleDeleteUser = async () => {
|
||||
if (!userResult.value) {
|
||||
return;
|
||||
}
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
if (typeof window !== 'undefined') {
|
||||
const confirmed = window.confirm('정말로 강제 탈퇴 처리하시겠습니까?');
|
||||
const confirmed = window.confirm(`${deletionRetentionDays.value}일 보존 후 탈퇴하도록 예약하시겠습니까?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await adminClient.users.forceDelete.mutate({ userId: userResult.value.id });
|
||||
userResult.value = null;
|
||||
forceDeleteStatus.value = '강제 탈퇴 완료';
|
||||
const result = await adminClient.users.scheduleDeletion.mutate({
|
||||
userId: userResult.value.id,
|
||||
retentionDays: deletionRetentionDays.value,
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
|
||||
forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`;
|
||||
await refreshUserHistory();
|
||||
} catch (error) {
|
||||
forceDeleteStatus.value = '강제 탈퇴 실패';
|
||||
forceDeleteStatus.value = '탈퇴 예약 실패';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1109,6 +1335,7 @@ const createLocalAccount = async () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadCapabilities();
|
||||
void loadLocalAccountStatus();
|
||||
void loadNotice();
|
||||
void loadProfiles();
|
||||
@@ -1153,7 +1380,7 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-8">
|
||||
<section class="space-y-6">
|
||||
<section class="min-w-0 space-y-6">
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<h3 class="text-lg font-semibold">유저 관리</h3>
|
||||
<form class="space-y-3" @submit.prevent="lookupUser">
|
||||
@@ -1192,6 +1419,16 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">
|
||||
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
|
||||
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
|
||||
</div>
|
||||
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
|
||||
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
|
||||
</div>
|
||||
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
|
||||
탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
|
||||
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
||||
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
||||
@@ -1200,6 +1437,18 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3">
|
||||
<h4 class="text-base font-semibold">민감 조치 공통 사유</h4>
|
||||
<input
|
||||
v-model="userActionReason"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="권한·제재·복구·탈퇴 조치 사유 (필수)"
|
||||
/>
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
|
||||
@@ -1266,6 +1515,38 @@ onMounted(() => {
|
||||
|
||||
<div 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 md:grid-cols-[1fr_1fr_auto]">
|
||||
<select
|
||||
v-model="selectedCapability"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="!hasUser"
|
||||
>
|
||||
<option
|
||||
v-for="capability in capabilities"
|
||||
:key="capability.permission"
|
||||
:value="capability.permission"
|
||||
>
|
||||
{{ capability.label }} · {{ capability.risk }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="capabilityProfile"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="Profile 범위 (예: che:default)"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
<button
|
||||
class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm"
|
||||
:disabled="!hasUser"
|
||||
@click="applyCapabilitySelection"
|
||||
>
|
||||
선택 반영
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedCapability" class="text-xs text-zinc-500">
|
||||
{{ capabilities.find((item) => item.permission === selectedCapability)?.description }}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<select
|
||||
v-model="rolesMode"
|
||||
@@ -1294,6 +1575,68 @@ onMounted(() => {
|
||||
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다.
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<input
|
||||
v-model="kakaoGraceUntil"
|
||||
type="datetime-local"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
<button
|
||||
class="bg-yellow-600 hover:bg-yellow-500 text-black px-4 py-2 rounded"
|
||||
:disabled="!hasUser"
|
||||
@click="updateKakaoGrace(false)"
|
||||
>
|
||||
유예 연장
|
||||
</button>
|
||||
<button
|
||||
class="bg-zinc-700 hover:bg-zinc-600 px-4 py-2 rounded"
|
||||
:disabled="!hasUser"
|
||||
@click="updateKakaoGrace(true)"
|
||||
>
|
||||
개별 유예 해제
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">{{ kakaoGraceStatus }}</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[620px] text-xs">
|
||||
<thead class="text-zinc-500">
|
||||
<tr>
|
||||
<th class="p-2 text-left">Profile</th>
|
||||
<th>접근</th>
|
||||
<th>장수 생성</th>
|
||||
<th>기본 접근 유예</th>
|
||||
<th>종료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="policy in kakaoPolicies"
|
||||
:key="policy.profileName"
|
||||
class="border-t border-zinc-800"
|
||||
>
|
||||
<td class="p-2">{{ policy.profileName }}</td>
|
||||
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
|
||||
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
||||
<td class="text-center">
|
||||
{{
|
||||
policy.graceEndsAt
|
||||
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
|
||||
: '-'
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
@@ -1400,19 +1743,109 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<h4 class="text-base font-semibold text-red-400">관리자 탈퇴 예약</h4>
|
||||
<input
|
||||
v-model.number="deletionRetentionDays"
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
aria-label="탈퇴 전 보존 일수"
|
||||
:disabled="!hasUser"
|
||||
/>
|
||||
<button
|
||||
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
|
||||
:disabled="!hasUser"
|
||||
@click="forceDeleteUser"
|
||||
@click="scheduleDeleteUser"
|
||||
>
|
||||
강제 탈퇴 처리
|
||||
보존 기간 후 탈퇴 예약
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
<div
|
||||
v-for="event in userHistory"
|
||||
:key="event.id"
|
||||
class="border border-zinc-800 rounded p-3 text-xs"
|
||||
>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span
|
||||
:class="
|
||||
event.outcome === 'FAILED'
|
||||
? 'text-red-300'
|
||||
: event.outcome === 'SUCCEEDED'
|
||||
? 'text-emerald-300'
|
||||
: 'text-amber-300'
|
||||
"
|
||||
>
|
||||
{{ event.outcome }} · {{ event.action }}
|
||||
</span>
|
||||
<span class="text-zinc-500">{{
|
||||
new Date(event.createdAt).toLocaleString('ko-KR')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
|
||||
</div>
|
||||
<div v-if="event.errorMessage" class="text-red-300">{{ event.errorMessage }}</div>
|
||||
<pre class="mt-2 overflow-auto whitespace-pre-wrap text-[11px] text-zinc-500">{{
|
||||
JSON.stringify(event.summary, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-6">
|
||||
<section class="min-w-0 space-y-6">
|
||||
<div
|
||||
v-if="capabilities.some((entry) => entry.permission === 'admin.audit.read')"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-semibold">전체 관리자 감사 원장</h3>
|
||||
<button
|
||||
class="rounded bg-zinc-700 px-3 py-2 text-xs hover:bg-zinc-600"
|
||||
@click="loadGlobalAudit"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="globalAuditStatus" class="text-xs text-red-400">{{ globalAuditStatus }}</div>
|
||||
<div v-if="!globalAuditHistory.length" class="text-xs text-zinc-500">기록이 없습니다.</div>
|
||||
<div v-else class="max-h-96 space-y-2 overflow-auto">
|
||||
<div
|
||||
v-for="event in globalAuditHistory"
|
||||
:key="event.id"
|
||||
class="rounded border border-zinc-800 p-3 text-xs"
|
||||
>
|
||||
<div class="flex justify-between gap-3">
|
||||
<span
|
||||
:class="
|
||||
event.outcome === 'FAILED'
|
||||
? 'text-red-300'
|
||||
: event.outcome === 'SUCCEEDED'
|
||||
? 'text-emerald-300'
|
||||
: 'text-amber-300'
|
||||
"
|
||||
>
|
||||
{{ event.outcome }} · {{ event.action }}
|
||||
</span>
|
||||
<span class="text-zinc-500">{{
|
||||
new Date(event.createdAt).toLocaleString('ko-KR')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
|
||||
{{ event.targetId ?? event.profileName ?? '' }} · {{ event.reason ?? '사유 없음' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">서버 공지</h3>
|
||||
@@ -1510,6 +1943,36 @@ onMounted(() => {
|
||||
placeholder="예: 12"
|
||||
/>
|
||||
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div>
|
||||
<label class="text-xs text-zinc-400">Kakao 미인증 접근 유예일</label>
|
||||
<input
|
||||
v-model="profileEdits[profile.profileName].localAccountAccessGraceDays"
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="비우면 Gateway 기본값"
|
||||
/>
|
||||
<label class="text-xs text-zinc-400">Kakao 미인증 장수 생성 유예일</label>
|
||||
<input
|
||||
v-model="profileEdits[profile.profileName].localAccountGeneralCreationGraceDays"
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="비우면 서버 기본값"
|
||||
/>
|
||||
<div class="text-xs text-zinc-500">
|
||||
게임 규칙 자체가 아니라 Gateway가 game token을 발급할 때 적용하는 profile별 계정
|
||||
정책입니다.
|
||||
</div>
|
||||
<label class="text-xs text-zinc-400">메타 변경 사유</label>
|
||||
<input
|
||||
v-model="profileEdits[profile.profileName].reason"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="변경 사유 (필수)"
|
||||
/>
|
||||
<button
|
||||
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
|
||||
@click="updateProfileMeta(profile.profileName)"
|
||||
|
||||
Reference in New Issue
Block a user