feat(gateway): recover orphaned Kakao account links

This commit is contained in:
2026-08-08 10:01:23 +00:00
parent 4374c490ac
commit 5c0aac5561
13 changed files with 634 additions and 27 deletions
@@ -0,0 +1,111 @@
import { expect, test, type Page, type Route } from '@playwright/test';
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, action: 'link_existing' | 'rejoin') => {
const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = operations.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.kakaoExchange') {
return response({
status: 'account_recovery',
action,
oauthSessionId: `${action}-session`,
email: 'retained@example.test',
});
}
if (operation === 'auth.kakaoResolveAccount') {
return action === 'link_existing'
? response({
status: 'otp',
successStatus: 'login',
challengeId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-08T12:03:00.000Z',
attemptsRemaining: 3,
})
: response({
status: 'join',
oauthSessionId: 'confirmed-registration-session',
email: 'retained@example.test',
});
}
throw new Error(`Unhandled Kakao account recovery fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return calls;
};
const verifyRecoveryChoice = async (page: Page, action: 'link_existing' | 'rejoin') => {
const group = page.getByRole('group', { name: '카카오 계정 연결 확인' });
const confirm = group.getByRole('button', {
name: action === 'link_existing' ? '기존 계정에 연결' : '재가입',
});
await expect(group).toBeVisible();
await expect(group).toContainText('retained@example.test');
await expect(group).toContainText(
action === 'link_existing' ? '이 계정에 카카오 로그인을 연결해드릴까요?' : '새 계정으로 재가입하시겠습니까?'
);
const geometry = await group.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
height: rect.height,
backgroundColor: style.backgroundColor,
fontSize: style.fontSize,
};
});
expect(geometry.width).toBeGreaterThan(300);
expect(geometry.backgroundColor).toBe('rgba(0, 0, 0, 0)');
await confirm.hover();
await page.waitForTimeout(200);
expect(await confirm.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(47, 77, 108)');
await confirm.focus();
expect(await confirm.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid');
return { group, confirm, geometry };
};
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
] as const) {
test(`links the retained email account only after confirmation on ${viewport.name}`, async ({ page }) => {
const calls = await installFixture(page, 'link_existing');
await page.setViewportSize(viewport);
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
const { confirm, geometry } = await verifyRecoveryChoice(page, 'link_existing');
await confirm.click();
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
});
test(`continues an orphaned Kakao connection as a new registration on ${viewport.name}`, async ({ page }) => {
const calls = await installFixture(page, 'rejoin');
await page.setViewportSize(viewport);
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
const { confirm, geometry } = await verifyRecoveryChoice(page, 'rejoin');
await confirm.click();
await expect(page.getByRole('heading', { name: '회원가입' })).toBeVisible();
await expect(page.getByLabel('카카오 이메일')).toHaveValue('retained@example.test');
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
});
}
@@ -17,6 +17,7 @@ export default defineConfig({
'legacy-log-html.spec.ts',
'gateway-notice-html.spec.ts',
'kakao-otp.spec.ts',
'kakao-account-recovery.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -22,6 +22,11 @@ const displayName = ref('');
const termsAgreed = ref(false);
const privacyAgreed = ref(false);
const thirdPartyUse = ref(false);
const accountRecovery = ref<{
action: 'link_existing' | 'rejoin';
oauthSessionId: string;
email: string;
} | null>(null);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const otpSuccessStatus = ref<'login' | 'verified'>('login');
const appBase = import.meta.env.BASE_URL;
@@ -55,6 +60,11 @@ const completeExchange = async (): Promise<void> => {
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
return;
}
if (result.status === 'account_recovery') {
accountRecovery.value = result;
email.value = result.email;
return;
}
oauthSessionId.value = result.oauthSessionId;
email.value = result.email;
} catch (error) {
@@ -64,6 +74,35 @@ const completeExchange = async (): Promise<void> => {
}
};
const resolveAccount = async (): Promise<void> => {
if (!accountRecovery.value) return;
errorMessage.value = '';
submitting.value = true;
try {
const result = await trpc.auth.kakaoResolveAccount.mutate({
oauthSessionId: accountRecovery.value.oauthSessionId,
action: accountRecovery.value.action,
});
accountRecovery.value = null;
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
return;
}
if (result.status === 'login') {
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
return;
}
oauthSessionId.value = result.oauthSessionId;
email.value = result.email;
} 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) {
@@ -117,9 +156,31 @@ onMounted(() => {
<main id="oauth-container">
<h1>삼국지 모의전투 HiDCHe</h1>
<section class="oauth-card">
<h2>회원가입</h2>
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</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="카카오 계정 연결 확인">
<p v-if="accountRecovery.action === 'link_existing'">
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 계정에
카카오 로그인을 연결해드릴까요?
</p>
<p v-else>
<strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만
서비스에서 연결할 계정을 찾지 못했습니다. 계정으로 재가입하시겠습니까?
</p>
<div class="recovery-actions">
<button class="register-button" type="button" :disabled="submitting" @click="resolveAccount">
{{
submitting
? '처리 중...'
: accountRecovery.action === 'link_existing'
? '기존 계정에 연결'
: '재가입'
}}
</button>
<RouterLink class="back-link" to="/">취소</RouterLink>
</div>
</div>
<form v-else-if="oauthSessionId" @submit.prevent="register">
<div class="form-row">
<label for="oauth-email">카카오 이메일</label>
@@ -224,6 +285,33 @@ onMounted(() => {
padding: 18px;
}
.recovery-panel {
padding: 18px;
text-align: center;
}
.recovery-panel p {
margin: 0;
line-height: 1.6;
}
.recovery-panel strong {
color: #ffd180;
}
.recovery-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 18px;
}
.recovery-actions .register-button,
.recovery-actions .back-link {
margin: 0;
}
.form-row,
.agreement-row {
display: grid;