feat: 카카오 계정의 본인 복구와 인증 후 연결 교체를 추가한다
This commit is contained in:
1 parent
d1cdf8a85f
commit
c81d304073
22 files changed
+1338
-199
No files matched your search
@@ -7,7 +7,7 @@ const operationNames = (route: Route): string[] => {
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
const installFixture = async (page: Page, approved = true) => {
|
||||
const requests: Array<{ operation: string; body: string }> = [];
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session');
|
||||
@@ -33,7 +33,7 @@ const installFixture = async (page: Page) => {
|
||||
nextRetireAt: null,
|
||||
thirdPartyUse: false,
|
||||
deleteAfter: null,
|
||||
kakaoReplacementApprovedUntil: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
kakaoReplacementApprovedUntil: approved ? new Date(Date.now() + 86_400_000).toISOString() : null,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.notifications.get') {
|
||||
@@ -63,7 +63,7 @@ const installFixture = async (page: Page) => {
|
||||
return requests;
|
||||
};
|
||||
|
||||
test('starts an approved Kakao replacement with explicit permanent-retirement confirmation', async ({
|
||||
test('starts a Kakao replacement with confirmation before authenticating the new account', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = await installFixture(page);
|
||||
@@ -88,7 +88,7 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co
|
||||
|
||||
await button.click();
|
||||
await expect(page).toHaveURL(/replacement=started/);
|
||||
expect(confirmation).toContain('기존 카카오 계정은 영구 폐기');
|
||||
expect(confirmation).toContain('새 카카오톡 인증을 완료하면');
|
||||
const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart');
|
||||
expect(startRequest?.body).toContain('verify');
|
||||
expect(startRequest?.body).toContain('kakao-replacement-session');
|
||||
@@ -105,3 +105,13 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co
|
||||
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 });
|
||||
});
|
||||
|
||||
test('offers ordinary users account replacement without an administrator approval', async ({ page }) => {
|
||||
const requests = await installFixture(page, false);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.goto('account');
|
||||
await expect(page.getByText(/교체 승인 .*까지/)).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '새 카카오 계정으로 교체', exact: true }).click();
|
||||
await expect(page).toHaveURL(/replacement=started/);
|
||||
expect(requests.some(({ operation }) => operation === 'auth.kakaoStart')).toBe(true);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
@@ -172,3 +173,174 @@ for (const viewport of [
|
||||
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||
});
|
||||
}
|
||||
|
||||
const installSelfRecoveryFixture = async (page: Page, byPassword: boolean) => {
|
||||
const calls: string[] = [];
|
||||
let loggedIn = false;
|
||||
let passwordAttempts = 0;
|
||||
let release: (() => void) | undefined;
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = [];
|
||||
for (const operation of operationNames(route)) {
|
||||
calls.push(operation);
|
||||
let data: unknown;
|
||||
if (operation === 'me')
|
||||
data = loggedIn
|
||||
? {
|
||||
id: 'self-user',
|
||||
username: 'self-user',
|
||||
displayName: '복구 사용자',
|
||||
roles: ['user'],
|
||||
kakaoVerified: true,
|
||||
}
|
||||
: null;
|
||||
else if (operation === 'lobby.notice') data = '';
|
||||
else if (operation === 'lobby.profiles') data = [];
|
||||
else if (operation === 'auth.passwordKey')
|
||||
data = { keyId: 'self-link-key', publicKeyPem, algorithm: 'RSA-OAEP-256' };
|
||||
else if (operation === 'auth.kakaoExchange')
|
||||
data = byPassword
|
||||
? {
|
||||
status: 'join',
|
||||
oauthSessionId: '33333333-3333-4333-8333-333333333333',
|
||||
email: 'new@example.test',
|
||||
}
|
||||
: {
|
||||
status: 'account_recovery',
|
||||
action: 'link_existing',
|
||||
oauthSessionId: '33333333-3333-4333-8333-333333333333',
|
||||
email: 'retained@example.test',
|
||||
passwordRequired: false,
|
||||
};
|
||||
else if (operation === 'auth.kakaoLinkWithPassword') {
|
||||
const input = Object.values(
|
||||
route.request().postDataJSON() as Record<
|
||||
string,
|
||||
{ username: string; credential: { ciphertext: string } }
|
||||
>
|
||||
)[0]!;
|
||||
expect(input.username).toBe('self-user');
|
||||
expect(input.credential.ciphertext).not.toBe('fixture-password');
|
||||
passwordAttempts++;
|
||||
if (passwordAttempts === 1) {
|
||||
results.push({
|
||||
error: {
|
||||
message: '기존 계정명 또는 비밀번호가 일치하지 않습니다.',
|
||||
code: -32001,
|
||||
data: { code: 'UNAUTHORIZED', httpStatus: 401 },
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
data = {
|
||||
status: 'otp',
|
||||
successStatus: 'login',
|
||||
challengeId: '44444444-4444-4444-8444-444444444444',
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
attemptsRemaining: 3,
|
||||
};
|
||||
} else if (operation === 'auth.kakaoResolveAccount')
|
||||
data = {
|
||||
status: 'otp',
|
||||
successStatus: 'login',
|
||||
challengeId: '44444444-4444-4444-8444-444444444444',
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
attemptsRemaining: 3,
|
||||
};
|
||||
else if (operation === 'auth.kakaoOtp') {
|
||||
loggedIn = true;
|
||||
data = { status: 'login', sessionToken: 'fixture-self-recovered', validUntil: '2099-01-01T00:00:00Z' };
|
||||
} else throw new Error(`Unhandled self recovery operation: ${operation}`);
|
||||
results.push(response(data));
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
return {
|
||||
calls,
|
||||
release: () => {
|
||||
expect(release).toBeDefined();
|
||||
release!();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
for (const byPassword of [false, true]) {
|
||||
test(`self-recovers by ${byPassword ? 'existing password' : 'matching email'} and new talk OTP on ${viewport.name}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize(viewport);
|
||||
const f = await installSelfRecoveryFixture(page, byPassword);
|
||||
await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state');
|
||||
if (byPassword) {
|
||||
await page.getByText('기존 계정 비밀번호로 연결', { exact: true }).click();
|
||||
const form = page.getByRole('form', { name: '기존 비밀번호로 카카오 연결' });
|
||||
await form.getByLabel('기존 계정명').fill('self-user');
|
||||
await form.getByLabel('기존 비밀번호').fill('wrong-password');
|
||||
const button = form.getByRole('button', { name: '비밀번호 확인 후 연결' });
|
||||
await button.click();
|
||||
await expect(page.getByRole('alert')).toContainText('일치하지 않습니다.');
|
||||
await form.getByLabel('기존 비밀번호').fill('fixture-password');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await button.hover();
|
||||
expect(await button.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(47, 77, 108)');
|
||||
await page.mouse.down();
|
||||
expect(await button.evaluate((el) => el.matches(':active'))).toBe(true);
|
||||
await page.mouse.move(1, 1);
|
||||
await page.mouse.up();
|
||||
await button.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(button).toBeFocused();
|
||||
const geometry = await form.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
viewport: innerWidth,
|
||||
fontSize: getComputedStyle(el).fontSize,
|
||||
html: el.outerHTML,
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.overflow).toBe(0);
|
||||
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||
await writeFile(testInfo.outputPath('self-link-geometry.json'), JSON.stringify(geometry, null, 2));
|
||||
await writeFile(testInfo.outputPath('self-link-panel.html'), geometry.html);
|
||||
await testInfo.attach('self-link-geometry', {
|
||||
body: JSON.stringify(geometry),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('self-link-password.png'), fullPage: true });
|
||||
await button.click();
|
||||
await expect(form.getByRole('button')).toBeDisabled();
|
||||
await expect.poll(() => f.calls.filter((call) => call === 'auth.kakaoLinkWithPassword').length).toBe(2);
|
||||
f.release();
|
||||
} else {
|
||||
await page.getByRole('button', { name: '기존 계정에 연결', exact: true }).click();
|
||||
}
|
||||
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull();
|
||||
await page.screenshot({ path: testInfo.outputPath('self-link-before-commit.png'), fullPage: true });
|
||||
await dialog.locator('input').fill('1234');
|
||||
await dialog.getByRole('button', { name: '제출', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBe(
|
||||
'fixture-self-recovered'
|
||||
);
|
||||
expect(f.calls).not.toContain('auth.register');
|
||||
expect(f.calls).not.toContain('admin.users.setKakaoReplacementApproval');
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -87,11 +87,6 @@ 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');
|
||||
|
||||
@@ -321,7 +316,7 @@ const changePassword = async (): Promise<void> => {
|
||||
const startKakaoReplacement = async (): Promise<void> => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'새 카카오 계정 연결이 끝나면 기존 카카오 계정은 영구 폐기되어 다시 가입하거나 연결할 수 없습니다. 계속하시겠습니까?'
|
||||
'새 카카오톡 인증을 완료하면 로그인에 사용할 카카오 계정이 바뀌고 기존 세션이 종료됩니다. 계속하시겠습니까?'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -667,7 +662,7 @@ onBeforeUnmount(() => {
|
||||
· 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지
|
||||
</template>
|
||||
<button
|
||||
v-if="kakaoReplacementApproved"
|
||||
v-if="account.oauthType === 'KAKAO'"
|
||||
class="skin-button compact"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
|
||||
@@ -26,12 +26,19 @@ const email = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const linkUsername = ref('');
|
||||
const linkPassword = ref('');
|
||||
const passwordLinkOpen = ref(false);
|
||||
const handlePasswordLinkToggle = (event: Event): void => {
|
||||
if (event.currentTarget instanceof HTMLDetailsElement) passwordLinkOpen.value = event.currentTarget.open;
|
||||
};
|
||||
const displayName = ref('');
|
||||
const termsAgreed = ref(false);
|
||||
const privacyAgreed = ref(false);
|
||||
const thirdPartyUse = ref(false);
|
||||
const accountRecovery = ref<{
|
||||
action: 'link_existing' | 'rejoin';
|
||||
passwordRequired?: boolean;
|
||||
oauthSessionId: string;
|
||||
email: string;
|
||||
} | null>(null);
|
||||
@@ -102,20 +109,11 @@ const resolveAccount = async (): Promise<void> => {
|
||||
action: accountRecovery.value.action,
|
||||
});
|
||||
accountRecovery.value = null;
|
||||
if (result.status === 'deletion_pending') {
|
||||
handleDeletionPending(result);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
@@ -131,6 +129,36 @@ const resolveAccount = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const linkWithPassword = async (): Promise<void> => {
|
||||
const sessionId = accountRecovery.value?.oauthSessionId || oauthSessionId.value;
|
||||
if (!sessionId || submitting.value) return;
|
||||
submitting.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const credential = await sealPassword(linkPassword.value);
|
||||
const result = await trpc.auth.kakaoLinkWithPassword.mutate({
|
||||
oauthSessionId: sessionId,
|
||||
username: linkUsername.value,
|
||||
credential,
|
||||
});
|
||||
linkPassword.value = '';
|
||||
accountRecovery.value = null;
|
||||
oauthSessionId.value = '';
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = result.successStatus;
|
||||
} else {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '기존 계정 비밀번호를 확인하지 못했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setMigratedPassword = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
@@ -224,13 +252,15 @@ onMounted(() => {
|
||||
<section class="oauth-card">
|
||||
<h2>
|
||||
{{
|
||||
deletionPending
|
||||
? '탈퇴 취소'
|
||||
: accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
passwordLinkOpen && (oauthSessionId || accountRecovery)
|
||||
? '기존 계정 연결'
|
||||
: deletionPending
|
||||
? '탈퇴 취소'
|
||||
: accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||
@@ -241,17 +271,32 @@ onMounted(() => {
|
||||
@restored="handleOtpVerified"
|
||||
@cancel="router.replace('/')"
|
||||
/>
|
||||
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||
<p v-if="accountRecovery.action === 'link_existing'">
|
||||
<div
|
||||
v-else-if="accountRecovery && !passwordLinkOpen"
|
||||
class="recovery-panel"
|
||||
role="group"
|
||||
aria-label="카카오 계정 연결 확인"
|
||||
>
|
||||
<p v-if="accountRecovery.action === 'link_existing' && !accountRecovery.passwordRequired">
|
||||
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 이 계정에
|
||||
카카오 로그인을 연결해드릴까요?
|
||||
</p>
|
||||
<p v-else-if="accountRecovery.passwordRequired">
|
||||
새 카카오 이메일로 기존 연결을 확인했습니다. 이메일이 다르거나 추가 확인이 필요한 계정은
|
||||
아래에서 기존 계정 비밀번호를 확인해 주세요. 비밀번호를 모르면 관리자에게 복구를 요청해 주세요.
|
||||
</p>
|
||||
<p v-else>
|
||||
<strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만 이
|
||||
서비스에서 연결할 계정을 찾지 못했습니다. 새 계정으로 재가입하시겠습니까?
|
||||
</p>
|
||||
<div class="recovery-actions">
|
||||
<button class="register-button" type="button" :disabled="submitting" @click="resolveAccount">
|
||||
<button
|
||||
v-if="!accountRecovery.passwordRequired"
|
||||
class="register-button"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="resolveAccount"
|
||||
>
|
||||
{{
|
||||
submitting
|
||||
? '처리 중...'
|
||||
@@ -303,7 +348,7 @@ onMounted(() => {
|
||||
</button>
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</form>
|
||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||
<form v-else-if="oauthSessionId && !passwordLinkOpen" @submit.prevent="register">
|
||||
<div class="form-row">
|
||||
<label for="oauth-email">카카오 이메일</label>
|
||||
<input id="oauth-email" :value="email" readonly />
|
||||
@@ -358,6 +403,37 @@ onMounted(() => {
|
||||
{{ submitting ? '가입 중...' : '가입' }}
|
||||
</button>
|
||||
</form>
|
||||
<details
|
||||
v-if="oauthSessionId || accountRecovery"
|
||||
class="password-link-panel"
|
||||
:open="accountRecovery?.passwordRequired || passwordLinkOpen"
|
||||
@toggle="handlePasswordLinkToggle"
|
||||
>
|
||||
<summary>기존 계정 비밀번호로 연결</summary>
|
||||
<form aria-label="기존 비밀번호로 카카오 연결" @submit.prevent="linkWithPassword">
|
||||
<p>
|
||||
기존 카카오 인증을 사용할 수 없어도, 기존 계정 비밀번호와 새 카카오톡 인증으로 연결할 수
|
||||
있습니다.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label for="link-username">기존 계정명</label>
|
||||
<input id="link-username" v-model="linkUsername" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="link-password">기존 비밀번호</label>
|
||||
<input
|
||||
id="link-password"
|
||||
v-model="linkPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="register-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '확인 중...' : '비밀번호 확인 후 연결' }}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
<p v-if="errorMessage" class="oauth-error" role="alert">{{ errorMessage }}</p>
|
||||
<RouterLink v-if="!loading && !oauthSessionId && !deletionPending" class="back-link" to="/"
|
||||
>돌아가기</RouterLink
|
||||
@@ -410,6 +486,17 @@ onMounted(() => {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.password-link-panel {
|
||||
margin: 18px;
|
||||
}
|
||||
.password-link-panel summary {
|
||||
cursor: pointer;
|
||||
color: #6db9ff;
|
||||
}
|
||||
.password-link-panel p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recovery-panel {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
|
||||
Reference in new issue
Block a user