feat: 레거시 재이관과 계정 복구 기반을 추가

중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
2026-08-17 15:55:32 +00:00
parent 50e7d894e4
commit fc7de05017
33 changed files with 1720 additions and 150 deletions
@@ -1,3 +1,5 @@
import { generateKeyPairSync } from 'node:crypto';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
@@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const installPasswordSetupFixture = async (page: Page) => {
const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
calls.push(operation);
if (operation === 'me') return response(null);
if (operation === 'lobby.notice') return response('');
if (operation === 'lobby.profiles') return response([]);
if (operation === 'auth.passwordKey') {
return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
}
if (operation === 'auth.kakaoExchange') {
return response({
status: 'password_setup',
oauthSessionId: '11111111-1111-4111-8111-111111111112',
email: 'migrated@example.test',
successStatus: 'login',
});
}
if (operation === 'auth.kakaoSetPassword') {
return response({
status: 'otp',
challengeId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-17T12:03:00.000Z',
attemptsRemaining: 3,
});
}
throw new Error(`Unhandled password setup fixture operation: ${operation}`);
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
return calls;
};
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => {
@@ -108,4 +147,27 @@ for (const viewport of [
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
});
test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => {
const calls = await installPasswordSetupFixture(page);
await page.setViewportSize(viewport);
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
const form = page.getByRole('form', { name: '새 비밀번호 설정' });
await expect(form).toBeVisible();
await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.');
await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test');
const geometry = await form.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { width: rect.width, right: rect.right };
});
await form.getByLabel('새 비밀번호').fill('new-password-value');
await form.getByLabel('비밀번호 확인').fill('new-password-value');
await form.getByRole('button', { name: '새 비밀번호 설정' }).click();
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1);
expect(geometry.width).toBeGreaterThan(300);
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
});
}
@@ -14,6 +14,8 @@ const submitting = ref(false);
const errorMessage = ref('');
const infoMessage = ref('');
const oauthSessionId = ref('');
const passwordSetupSessionId = ref('');
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
const email = ref('');
const username = ref('');
const password = ref('');
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
return;
}
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
if (result.status === 'account_recovery') {
accountRecovery.value = result;
email.value = result.email;
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
await router.replace('/lobby');
return;
}
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
oauthSessionId.value = result.oauthSessionId;
email.value = result.email;
} catch (error) {
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
}
};
const setMigratedPassword = async (): Promise<void> => {
errorMessage.value = '';
if (password.value !== confirmPassword.value) {
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
return;
}
submitting.value = true;
try {
const credential = await sealPassword(password.value);
const result = await trpc.auth.kakaoSetPassword.mutate({
oauthSessionId: passwordSetupSessionId.value,
credential,
});
password.value = '';
confirmPassword.value = '';
passwordSetupSessionId.value = '';
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby');
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.';
} finally {
submitting.value = false;
}
};
const register = async (): Promise<void> => {
errorMessage.value = '';
if (password.value !== confirmPassword.value) {
@@ -156,7 +200,15 @@ onMounted(() => {
<main id="oauth-container">
<h1>삼국지 모의전투 HiDCHe</h1>
<section class="oauth-card">
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
<h2>
{{
accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
}}
</h2>
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
@@ -181,6 +233,46 @@ onMounted(() => {
<RouterLink class="back-link" to="/">취소</RouterLink>
</div>
</div>
<form
v-else-if="passwordSetupSessionId"
class="password-setup-form"
aria-label=" 비밀번호 설정"
@submit.prevent="setMigratedPassword"
>
<p class="oauth-message">
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 비밀번호를 설정해 주세요.
</p>
<div class="form-row">
<label for="migrated-password-email">카카오 이메일</label>
<input id="migrated-password-email" :value="email" readonly />
</div>
<div class="form-row">
<label for="migrated-password"> 비밀번호</label>
<input
id="migrated-password"
v-model="password"
type="password"
minlength="6"
autocomplete="new-password"
required
/>
</div>
<div class="form-row">
<label for="migrated-password-confirm">비밀번호 확인</label>
<input
id="migrated-password-confirm"
v-model="confirmPassword"
type="password"
minlength="6"
autocomplete="new-password"
required
/>
</div>
<button class="register-button" type="submit" :disabled="submitting">
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
</button>
<RouterLink class="back-link" to="/">취소</RouterLink>
</form>
<form v-else-if="oauthSessionId" @submit.prevent="register">
<div class="form-row">
<label for="oauth-email">카카오 이메일</label>