feat: 관리자 계정 식별자와 카카오 영구 교체 지원
관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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 requests: Array<{ operation: string; body: string }> = [];
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const body = route.request().postData() ?? '';
|
||||
const results = operationNames(route).map((operation) => {
|
||||
requests.push({ operation, body });
|
||||
if (operation === 'account.get') {
|
||||
return response({
|
||||
id: 'replacement-user',
|
||||
username: 'replacement-user',
|
||||
displayName: '교체 사용자',
|
||||
roles: ['user'],
|
||||
oauthType: 'KAKAO',
|
||||
email: 'replacement@example.test',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
iconUrl: null,
|
||||
icons: [],
|
||||
preferredPicture: 'default.jpg',
|
||||
maxActiveIcons: 5,
|
||||
nextUploadAt: null,
|
||||
nextRetireAt: null,
|
||||
thirdPartyUse: false,
|
||||
deleteAfter: null,
|
||||
kakaoReplacementApprovedUntil: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
});
|
||||
}
|
||||
if (operation === 'account.notifications.get') {
|
||||
return response({
|
||||
capability: { enabled: false, publicKey: null },
|
||||
eventTypes: [],
|
||||
profiles: [],
|
||||
preferences: [],
|
||||
subscriptionCount: 0,
|
||||
currentDeviceSubscribed: false,
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.kakaoStart') {
|
||||
return response({
|
||||
authUrl: `${new URL(route.request().url()).origin}/gateway/account?replacement=started`,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled account replacement fixture operation: ${operation}`);
|
||||
});
|
||||
const isBatch = new URL(route.request().url()).searchParams.get('batch') === '1';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(isBatch ? results : results[0]),
|
||||
});
|
||||
});
|
||||
return requests;
|
||||
};
|
||||
|
||||
test('starts an approved Kakao replacement with explicit permanent-retirement confirmation', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = await installFixture(page);
|
||||
let confirmation = '';
|
||||
page.on('dialog', async (dialog) => {
|
||||
confirmation = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.goto('account');
|
||||
const button = page.getByRole('button', { name: '새 카카오 계정으로 교체' });
|
||||
await expect(button).toBeVisible();
|
||||
await expect(page.getByText(/교체 승인 .*까지/)).toBeVisible();
|
||||
const initialBackground = await button.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await button.hover();
|
||||
await expect
|
||||
.poll(() => button.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(initialBackground);
|
||||
await button.focus();
|
||||
await expect(button).toBeFocused();
|
||||
await page.screenshot({ path: testInfo.outputPath('account-kakao-replacement-desktop.png'), fullPage: true });
|
||||
|
||||
await button.click();
|
||||
await expect(page).toHaveURL(/replacement=started/);
|
||||
expect(confirmation).toContain('기존 카카오 계정은 영구 폐기');
|
||||
const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart');
|
||||
expect(startRequest?.body).toContain('verify');
|
||||
expect(startRequest?.body).toContain('kakao-replacement-session');
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 844 });
|
||||
const mobileButton = page.getByRole('button', { name: '새 카카오 계정으로 교체' });
|
||||
await expect(mobileButton).toBeVisible();
|
||||
const geometry = await mobileButton.evaluate((element) => {
|
||||
const rect = element.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('account-kakao-replacement-mobile-geometry.json'), JSON.stringify(geometry));
|
||||
await page.screenshot({ path: testInfo.outputPath('account-kakao-replacement-mobile.png'), fullPage: true });
|
||||
});
|
||||
@@ -11,6 +11,9 @@ const installFixture = async (page: Page) => {
|
||||
const requests: Array<{ operation: string; body: unknown }> = [];
|
||||
let deleteAfter: string | null = null;
|
||||
let graceUntil: string | null = null;
|
||||
let username = 'target';
|
||||
let displayName = '대상 사용자';
|
||||
let kakaoReplacementApprovedUntil: string | null = null;
|
||||
let specialGrants: Array<Record<string, unknown>> = [];
|
||||
const auditHistory = [
|
||||
{
|
||||
@@ -74,10 +77,10 @@ const installFixture = async (page: Page) => {
|
||||
users: [
|
||||
{
|
||||
id: 'target-user',
|
||||
username: 'target',
|
||||
displayName: '대상 사용자',
|
||||
username,
|
||||
displayName,
|
||||
email: 'target@example.test',
|
||||
oauthType: 'NONE',
|
||||
oauthType: 'KAKAO',
|
||||
roles: ['user'],
|
||||
hasActiveSanction: false,
|
||||
deleteAfter,
|
||||
@@ -111,11 +114,13 @@ const installFixture = async (page: Page) => {
|
||||
if (operation === 'admin.users.lookup') {
|
||||
return response({
|
||||
id: 'target-user',
|
||||
username: 'target',
|
||||
displayName: '대상 사용자',
|
||||
username,
|
||||
displayName,
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
oauthType: 'NONE',
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: 'fixture-kakao-stable-id',
|
||||
kakaoReplacementApprovedUntil,
|
||||
kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z',
|
||||
kakaoGraceUntil: graceUntil,
|
||||
deleteAfter,
|
||||
@@ -164,6 +169,27 @@ const installFixture = async (page: Page) => {
|
||||
});
|
||||
return response({ kakaoGraceUntil: graceUntil });
|
||||
}
|
||||
if (operation === 'admin.users.updateIdentity') {
|
||||
username = 'target-renamed';
|
||||
displayName = '변경된 대상';
|
||||
auditHistory.unshift({
|
||||
...auditHistory[0],
|
||||
id: 'audit-identity',
|
||||
action: 'admin.users.updateIdentity',
|
||||
reason: '고객 본인 확인 완료',
|
||||
});
|
||||
return response({ username, displayName, identityRevision: '2026-08-24T12:00:00.000Z' });
|
||||
}
|
||||
if (operation === 'admin.users.setKakaoReplacementApproval') {
|
||||
kakaoReplacementApprovedUntil = '2026-08-26T00:00:00.000Z';
|
||||
auditHistory.unshift({
|
||||
...auditHistory[0],
|
||||
id: 'audit-kakao-replacement',
|
||||
action: 'admin.users.setKakaoReplacementApproval',
|
||||
reason: '기존 단말 분실 교체',
|
||||
});
|
||||
return response({ kakaoReplacementApprovedUntil });
|
||||
}
|
||||
if (operation === 'admin.users.grantSpecialAccess') {
|
||||
specialGrants = [
|
||||
{
|
||||
@@ -219,6 +245,18 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: '사용자 관리 기능' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden();
|
||||
await expect(page.getByRole('heading', { name: 'ID · 닉네임 변경' })).toBeVisible();
|
||||
await page.getByLabel('로그인 ID').fill('target-renamed');
|
||||
await page.getByLabel('닉네임').fill('변경된 대상');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('고객 본인 확인 완료');
|
||||
await page.getByRole('button', { name: 'ID · 닉네임 변경', exact: true }).click();
|
||||
await expect(page.getByText('ID와 닉네임을 변경했습니다.').first()).toBeVisible();
|
||||
await page.getByLabel('Kakao 계정 교체 승인 만료 시각').fill('2026-08-26T00:00');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('기존 단말 분실 교체');
|
||||
await page.getByRole('button', { name: '교체 승인', exact: true }).click();
|
||||
await expect(page.getByText('새 카카오 계정 교체를 승인했습니다.').first()).toBeVisible();
|
||||
expect(requests.some(({ operation }) => operation === 'admin.users.updateIdentity')).toBe(true);
|
||||
expect(requests.some(({ operation }) => operation === 'admin.users.setKakaoReplacementApproval')).toBe(true);
|
||||
await page.getByRole('button', { name: /접근 · 권한/ }).click();
|
||||
await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1);
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
'server-operations.spec.ts',
|
||||
'admin-runtime-actions.spec.ts',
|
||||
'admin-account-controls.spec.ts',
|
||||
'account-kakao-replacement.spec.ts',
|
||||
'lobby-admin-navigation.spec.ts',
|
||||
'lobby-game-auth.spec.ts',
|
||||
'logout.spec.ts',
|
||||
|
||||
@@ -86,6 +86,11 @@ const currentProfile = computed(() =>
|
||||
const notificationEventTypes = computed(
|
||||
() => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[]
|
||||
);
|
||||
const kakaoReplacementApproved = computed(
|
||||
() =>
|
||||
Boolean(account.value?.kakaoReplacementApprovedUntil) &&
|
||||
new Date(account.value!.kakaoReplacementApprovedUntil!).getTime() > Date.now()
|
||||
);
|
||||
|
||||
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
|
||||
|
||||
@@ -312,6 +317,22 @@ const changePassword = async (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const startKakaoReplacement = async (): Promise<void> => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'새 카카오 계정 연결이 끝나면 기존 카카오 계정은 영구 폐기되어 다시 가입하거나 연결할 수 없습니다. 계속하시겠습니까?'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
const result = await trpc.auth.kakaoStart.query({ mode: 'verify', sessionToken: token });
|
||||
window.location.assign(result.authUrl);
|
||||
});
|
||||
};
|
||||
|
||||
const disallowThirdPartyUse = async (): Promise<void> => {
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
@@ -639,7 +660,21 @@ onBeforeUnmount(() => {
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">인증 방식</th>
|
||||
<td colspan="5">{{ account.oauthType }}</td>
|
||||
<td colspan="5">
|
||||
{{ account.oauthType }}
|
||||
<template v-if="account.kakaoReplacementApprovedUntil">
|
||||
· 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지
|
||||
</template>
|
||||
<button
|
||||
v-if="kakaoReplacementApproved"
|
||||
class="skin-button compact"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="startKakaoReplacement"
|
||||
>
|
||||
새 카카오 계정으로 교체
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1"></th>
|
||||
|
||||
@@ -99,6 +99,8 @@ type AdminUser = {
|
||||
kakaoVerifiedAt?: string;
|
||||
kakaoGraceStartedAt: string;
|
||||
kakaoGraceUntil?: string;
|
||||
identityRevision?: string;
|
||||
kakaoReplacementApprovedUntil?: string;
|
||||
profileIconResetAt?: string;
|
||||
deleteAfter?: string;
|
||||
createdAt: string;
|
||||
@@ -289,6 +291,19 @@ type AdminClient = {
|
||||
kakaoGraceUntil: string | null;
|
||||
}>;
|
||||
};
|
||||
setKakaoReplacementApproval: {
|
||||
mutate: (input: { userId: string; until: string | null; reason: string }) => Promise<{
|
||||
kakaoReplacementApprovedUntil: string | null;
|
||||
}>;
|
||||
};
|
||||
updateIdentity: {
|
||||
mutate: (input: {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
reason: string;
|
||||
}) => Promise<{ username: string; displayName: string; identityRevision?: string }>;
|
||||
};
|
||||
grantSpecialAccess: {
|
||||
mutate: (input: {
|
||||
userId: string;
|
||||
@@ -544,6 +559,11 @@ const localAccountForm = ref({
|
||||
const passwordInput = ref('');
|
||||
const passwordResult = ref('');
|
||||
const passwordStatus = ref('');
|
||||
const identityUsername = ref('');
|
||||
const identityDisplayName = ref('');
|
||||
const identityStatus = ref('');
|
||||
const kakaoReplacementUntil = ref('');
|
||||
const kakaoReplacementStatus = ref('');
|
||||
|
||||
const rolesInput = ref('');
|
||||
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
|
||||
@@ -624,6 +644,8 @@ const actionFeedback = [
|
||||
noticeStatus,
|
||||
userError,
|
||||
kakaoGraceStatus,
|
||||
identityStatus,
|
||||
kakaoReplacementStatus,
|
||||
specialAccessStatus,
|
||||
passwordStatus,
|
||||
rolesStatus,
|
||||
@@ -1025,6 +1047,11 @@ const lookupUser = async () => {
|
||||
return;
|
||||
}
|
||||
userResult.value = result;
|
||||
identityUsername.value = result.username;
|
||||
identityDisplayName.value = result.displayName;
|
||||
kakaoReplacementUntil.value = result.kakaoReplacementApprovedUntil
|
||||
? toLocalInputValue(result.kakaoReplacementApprovedUntil)
|
||||
: toLocalInputValue(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
|
||||
const [grace, history] = await Promise.all([
|
||||
adminClient.users.getKakaoGracePolicies.query({ userId: result.id }),
|
||||
adminClient.users.listHistory.query({ userId: result.id, limit: 50 }),
|
||||
@@ -1137,6 +1164,56 @@ const updateKakaoGrace = async (clear = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserIdentity = async () => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
identityStatus.value = '';
|
||||
try {
|
||||
const result = await adminClient.users.updateIdentity.mutate({
|
||||
userId: userResult.value.id,
|
||||
username: identityUsername.value,
|
||||
displayName: identityDisplayName.value,
|
||||
reason,
|
||||
});
|
||||
userResult.value = { ...userResult.value, ...result };
|
||||
identityUsername.value = result.username;
|
||||
identityDisplayName.value = result.displayName;
|
||||
identityStatus.value = 'ID와 닉네임을 변경했습니다.';
|
||||
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
||||
} catch {
|
||||
identityStatus.value = 'ID 또는 닉네임 변경에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const setKakaoReplacementApproval = async (clear = false) => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
if (!reason) return;
|
||||
const until = clear ? null : (serverDateTimeInputToIso(kakaoReplacementUntil.value) ?? null);
|
||||
if (!clear && !until) {
|
||||
kakaoReplacementStatus.value = '올바른 교체 승인 만료 시각을 입력하세요.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await adminClient.users.setKakaoReplacementApproval.mutate({
|
||||
userId: userResult.value.id,
|
||||
until,
|
||||
reason,
|
||||
});
|
||||
userResult.value = {
|
||||
...userResult.value,
|
||||
kakaoReplacementApprovedUntil: result.kakaoReplacementApprovedUntil ?? undefined,
|
||||
};
|
||||
kakaoReplacementStatus.value = result.kakaoReplacementApprovedUntil
|
||||
? '새 카카오 계정 교체를 승인했습니다.'
|
||||
: '카카오 계정 교체 승인을 해제했습니다.';
|
||||
await refreshUserHistory();
|
||||
} catch {
|
||||
kakaoReplacementStatus.value = '카카오 계정 교체 승인 변경에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const grantSpecialAccess = async () => {
|
||||
if (!userResult.value) return;
|
||||
const reason = requireUserActionReason();
|
||||
@@ -1608,6 +1685,10 @@ onMounted(() => {
|
||||
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
|
||||
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
|
||||
</div>
|
||||
<div v-if="userResult.kakaoReplacementApprovedUntil" class="text-xs text-amber-300">
|
||||
Kakao 교체 승인:
|
||||
{{ formatServerDateTime(userResult.kakaoReplacementApprovedUntil) }}까지
|
||||
</div>
|
||||
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
|
||||
탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
|
||||
</div>
|
||||
@@ -1704,6 +1785,75 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'account' && hasUser"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">ID · 닉네임 변경</h4>
|
||||
<p class="text-xs text-zinc-400">
|
||||
사용자는 직접 바꿀 수 없습니다. 닉네임은 현재 장수에 반영되며 과거 장수와 명예의 전당의 당시
|
||||
기록은 유지됩니다.
|
||||
</p>
|
||||
<div class="grid gap-2">
|
||||
<label class="text-xs text-zinc-400" for="admin-identity-username">로그인 ID</label>
|
||||
<input
|
||||
id="admin-identity-username"
|
||||
v-model="identityUsername"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
<label class="text-xs text-zinc-400" for="admin-identity-display-name">닉네임</label>
|
||||
<input
|
||||
id="admin-identity-display-name"
|
||||
v-model="identityDisplayName"
|
||||
type="text"
|
||||
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
||||
@click="updateUserIdentity"
|
||||
>
|
||||
ID · 닉네임 변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">{{ identityStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'account' && hasUser && userResult?.oauthType === 'KAKAO'"
|
||||
class="bg-zinc-900 border border-red-900/70 rounded-lg p-5 space-y-4"
|
||||
>
|
||||
<h4 class="text-base font-semibold">Kakao 계정 영구 교체</h4>
|
||||
<p class="text-xs text-zinc-400">
|
||||
승인 뒤 사용자가 계정 관리에서 새 Kakao 계정의 소유권을 직접 증명합니다. 교체가 완료되면
|
||||
기존 Kakao stable ID는 영구 폐기되어 로그인·재가입·재연결에 쓸 수 없습니다.
|
||||
</p>
|
||||
<input
|
||||
v-model="kakaoReplacementUntil"
|
||||
type="datetime-local"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
aria-label="Kakao 계정 교체 승인 만료 시각"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
|
||||
@click="setKakaoReplacementApproval(false)"
|
||||
>
|
||||
교체 승인
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 bg-zinc-700 hover:bg-zinc-600 px-4 py-2 rounded"
|
||||
@click="setKakaoReplacementApproval(true)"
|
||||
>
|
||||
승인 해제
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">{{ kakaoReplacementStatus }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userWorkspaceSection === 'access'"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||
|
||||
Reference in New Issue
Block a user