feat: 탈퇴 취소와 이관 계정 복구 예외를 추가한다
This commit is contained in:
1 parent
f16d33e34c
commit
257a2bea10
21 files changed
+730
-109
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
const pending = {
|
||||
status: 'deletion_pending',
|
||||
recoveryId: '11111111-1111-4111-8111-111111111111',
|
||||
deleteAfter: '2026-07-30T14:59:59.999Z',
|
||||
};
|
||||
const otp = {
|
||||
status: 'otp',
|
||||
successStatus: 'login',
|
||||
challengeId: '22222222-2222-4222-8222-222222222222',
|
||||
expiresAt: '2026-11-07T12:00:00Z',
|
||||
attemptsRemaining: 3,
|
||||
};
|
||||
const installFixture = async (page: Page, entry: 'password' | 'kakao' | 'kakao-link', fail = false) => {
|
||||
const calls: string[] = [];
|
||||
let restored = false;
|
||||
let complete: (() => void) | undefined;
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const names = decodeURIComponent(url.pathname.split('/trpc/')[1]!).split(',');
|
||||
const results = [];
|
||||
for (const name of names) {
|
||||
calls.push(name);
|
||||
let data: unknown;
|
||||
if (name === 'me')
|
||||
data = restored
|
||||
? {
|
||||
id: 'fixture-user',
|
||||
username: 'fixture-user',
|
||||
displayName: '복구계정',
|
||||
roles: ['user'],
|
||||
kakaoVerified: true,
|
||||
}
|
||||
: null;
|
||||
else if (name === 'lobby.profiles') data = [];
|
||||
else if (name === 'lobby.notice') data = '';
|
||||
else if (name === 'auth.passwordKey')
|
||||
data = { keyId: 'fixture-key', publicKeyPem, algorithm: 'RSA-OAEP-256' };
|
||||
else if (name === 'auth.login') data = pending;
|
||||
else if (name === 'auth.kakaoExchange')
|
||||
data =
|
||||
entry === 'kakao-link'
|
||||
? {
|
||||
status: 'account_recovery',
|
||||
action: 'link_existing',
|
||||
oauthSessionId: 'fixture-link-session',
|
||||
email: 'retained@example.test',
|
||||
}
|
||||
: pending;
|
||||
else if (name === 'auth.kakaoResolveAccount') data = otp;
|
||||
else if (name === 'auth.kakaoOtp') data = { ...pending, validUntil: '2026-11-08T12:00:00Z' };
|
||||
else if (name === 'auth.cancelDeletion') {
|
||||
const input = route.request().postDataJSON() as Record<string, { recoveryId: string }>;
|
||||
expect(Object.values(input)).toEqual([{ recoveryId: pending.recoveryId }]);
|
||||
await new Promise<void>((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
if (fail) {
|
||||
results.push({
|
||||
error: {
|
||||
message: '복구 인증이 만료되었습니다. 다시 로그인해 주세요.',
|
||||
code: -32001,
|
||||
data: { code: 'UNAUTHORIZED', httpStatus: 401 },
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
restored = true;
|
||||
data = {
|
||||
status: 'login',
|
||||
sessionToken: 'fixture-restored-session',
|
||||
user: { id: 'fixture-user' },
|
||||
issuedAt: '2026-11-07T12:00:00Z',
|
||||
};
|
||||
} else throw new Error(`Unhandled recovery operation: ${name}`);
|
||||
results.push({ result: { data } });
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
return {
|
||||
calls,
|
||||
complete: () => {
|
||||
expect(complete).toBeDefined();
|
||||
complete!();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
for (const entry of ['password', 'kakao', 'kakao-link'] as const) {
|
||||
test(`restores a pending account through ${entry} on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport);
|
||||
const f = await installFixture(page, entry);
|
||||
await page.goto(
|
||||
entry === 'password' ? '/gateway/' : '/gateway/oauth/callback?code=fixture-code&state=fixture-state'
|
||||
);
|
||||
if (entry === 'password') {
|
||||
await page.getByLabel('계정명', { exact: true }).fill('fixture-user');
|
||||
await page.getByLabel('비밀번호', { exact: true }).fill('fixture-password');
|
||||
await page.getByRole('button', { name: '로그인', exact: true }).click();
|
||||
}
|
||||
if (entry === 'kakao-link') {
|
||||
await expect(page.getByRole('group', { name: '카카오 계정 연결 확인' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '기존 계정에 연결' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.locator('input').fill('1234');
|
||||
await dialog.getByRole('button', { name: '제출', exact: true }).click();
|
||||
}
|
||||
const panel = page.getByRole('group', { name: '탈퇴 취소', exact: true });
|
||||
await expect(panel).toBeVisible();
|
||||
expect(f.calls).not.toContain('auth.cancelDeletion');
|
||||
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await panel.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
textAlign: style.textAlign,
|
||||
fontSize: style.fontSize,
|
||||
html: element.outerHTML,
|
||||
};
|
||||
});
|
||||
expect(geometry.x).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.width).toBeLessThan(viewport.width);
|
||||
expect(geometry.textAlign).toBe('center');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(viewport.width);
|
||||
const button = panel.getByRole('button', { name: '탈퇴 취소', exact: true });
|
||||
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(0, 0);
|
||||
await page.mouse.up();
|
||||
await button.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(button).toBeFocused();
|
||||
expect(await button.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid');
|
||||
await writeFile(testInfo.outputPath('panel-dom-geometry-style.json'), JSON.stringify(geometry, null, 2));
|
||||
await writeFile(testInfo.outputPath('panel.html'), geometry.html);
|
||||
await testInfo.attach('panel-dom-geometry-style', {
|
||||
body: JSON.stringify(geometry, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('deletion-confirmation.png'), fullPage: true });
|
||||
await button.click();
|
||||
await expect(panel.getByRole('button', { name: '처리 중...' })).toBeDisabled();
|
||||
await expect(panel.getByRole('button', { name: '돌아가기' })).toBeDisabled();
|
||||
await expect.poll(() => f.calls.filter((call) => call === 'auth.cancelDeletion').length).toBe(1);
|
||||
f.complete();
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBe(
|
||||
'fixture-restored-session'
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps deletion unchanged when leaving confirmation or when recovery expires', async ({ page }) => {
|
||||
const f = await installFixture(page, 'kakao', true);
|
||||
await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state');
|
||||
const panel = page.getByRole('group', { name: '탈퇴 취소', exact: true });
|
||||
await panel.getByRole('button', { name: '돌아가기' }).click();
|
||||
await expect(page).toHaveURL(/\/gateway\/$/);
|
||||
expect(f.calls).not.toContain('auth.cancelDeletion');
|
||||
await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state');
|
||||
await panel.getByRole('button', { name: '탈퇴 취소' }).click();
|
||||
await expect.poll(() => f.calls.filter((call) => call === 'auth.cancelDeletion').length).toBe(1);
|
||||
f.complete();
|
||||
await expect(panel.getByRole('alert')).toContainText('다시 로그인');
|
||||
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull();
|
||||
});
|
||||
@@ -98,6 +98,7 @@ const verifyRecoveryChoice = async (page: Page, action: 'link_existing' | 'rejoi
|
||||
await expect(group).toContainText(
|
||||
action === 'link_existing' ? '이 계정에 카카오 로그인을 연결해드릴까요?' : '새 계정으로 재가입하시겠습니까?'
|
||||
);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const geometry = await group.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
@@ -133,7 +133,7 @@ const verifyDialog = async (page: Page, artifactName: string) => {
|
||||
await expect(submit).toBeFocused();
|
||||
await page.waitForTimeout(200);
|
||||
expect(await submit.evaluate((element) => getComputedStyle(element).boxShadow)).toMatch(
|
||||
/^rgba\(85, 115, 146, 0\.49\d\) 0px 0px 0px 4px$/
|
||||
/^rgba\(85, 115, 146, 0\.(?:49\d|5)\) 0px 0px 0px 4px$/
|
||||
);
|
||||
|
||||
if (artifactRoot) {
|
||||
|
||||
@@ -60,8 +60,8 @@ const loginGame = async (username: string, password: string) => {
|
||||
).toString('base64'),
|
||||
};
|
||||
const login = await gateway.auth.login.mutate({ username, credential });
|
||||
if (login.status === 'otp') {
|
||||
throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.');
|
||||
if (login.status !== 'login') {
|
||||
throw new Error(`Live lifecycle fixture requires login; received ${login.status}.`);
|
||||
}
|
||||
gatewaySession.token = login.sessionToken;
|
||||
const issued = await gateway.auth.issueGameSession.mutate({
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
'gateway-notice-html.spec.ts',
|
||||
'kakao-otp.spec.ts',
|
||||
'kakao-account-recovery.spec.ts',
|
||||
'account-deletion-recovery.spec.ts',
|
||||
'public-map-tabs.spec.ts',
|
||||
'runtime-navigation.spec.ts',
|
||||
'open-suggestion.spec.ts',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
export interface DeletionPending {
|
||||
recoveryId: string;
|
||||
deleteAfter: string;
|
||||
}
|
||||
const props = defineProps<{ recovery: DeletionPending }>();
|
||||
const emit = defineEmits<{ restored: [sessionToken: string]; cancel: [] }>();
|
||||
const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const restore = async (): Promise<void> => {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = await trpc.auth.cancelDeletion.mutate({ recoveryId: props.recovery.recoveryId });
|
||||
if (result.status !== 'login') throw new Error('계정 상태가 변경되었습니다. 다시 로그인해 주세요.');
|
||||
emit('restored', result.sessionToken);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '탈퇴를 취소하지 못했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="deletion-recovery" role="group" aria-label="탈퇴 취소">
|
||||
<p>탈퇴 신청된 계정입니다.</p>
|
||||
<p>삭제 예정일: {{ formatServerDateTime(recovery.deleteAfter) }}</p>
|
||||
<p>계정을 계속 사용하려면 탈퇴를 취소해 주세요.</p>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="submitting" @click="restore">
|
||||
{{ submitting ? '처리 중...' : '탈퇴 취소' }}
|
||||
</button>
|
||||
<button type="button" :disabled="submitting" @click="emit('cancel')">돌아가기</button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="recovery-error" role="alert">{{ errorMessage }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.deletion-recovery {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
.deletion-recovery p {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions button {
|
||||
min-height: 42px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #2f4d6c;
|
||||
border-radius: 4px;
|
||||
background: #375a7f;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions button:hover,
|
||||
.actions button:active {
|
||||
background: #2f4d6c;
|
||||
}
|
||||
.actions button:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.recovery-error {
|
||||
color: #ff8a80;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
import type { DeletionPending } from './DeletionRecoveryPanel.vue';
|
||||
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -10,6 +12,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
verified: [sessionToken: string, validUntil: string];
|
||||
cancel: [];
|
||||
deletionPending: [recovery: DeletionPending];
|
||||
}>();
|
||||
|
||||
const code = ref('');
|
||||
@@ -36,6 +39,10 @@ const submit = async (): Promise<void> => {
|
||||
challengeId: props.challengeId,
|
||||
code: code.value,
|
||||
});
|
||||
if (result.status === 'deletion_pending') {
|
||||
emit('deletionPending', result);
|
||||
return;
|
||||
}
|
||||
emit('verified', result.sessionToken, result.validUntil);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '인증 코드를 확인하지 못했습니다.';
|
||||
|
||||
@@ -831,7 +831,9 @@ onBeforeUnmount(() => {
|
||||
현재 아이콘 서버 적용
|
||||
</button>
|
||||
</p>
|
||||
<p class="warning">탈퇴시 1개월간 정보가 보존되며, 1개월간 재가입이 불가능합니다.</p>
|
||||
<p class="warning">
|
||||
탈퇴시 1개월간 정보가 보존되며, 보존 기간에는 로그인 후 탈퇴를 취소할 수 있습니다.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common/gateway/profileStat
|
||||
|
||||
import MapPreview from '../components/MapPreview.vue';
|
||||
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
|
||||
import DeletionRecoveryPanel, { type DeletionPending } from '../components/DeletionRecoveryPanel.vue';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -27,6 +28,12 @@ const username = ref('');
|
||||
const password = ref('');
|
||||
const loginError = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const deletionPending = ref<DeletionPending | null>(null);
|
||||
const handleDeletionPending = (recovery: DeletionPending): void => {
|
||||
otpChallenge.value = null;
|
||||
password.value = '';
|
||||
deletionPending.value = recovery;
|
||||
};
|
||||
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
|
||||
const statusLoading = ref(false);
|
||||
const statusError = ref('');
|
||||
@@ -101,6 +108,10 @@ const handleLogin = async (): Promise<void> => {
|
||||
username: username.value,
|
||||
credential,
|
||||
});
|
||||
if (result.status === 'deletion_pending') {
|
||||
handleDeletionPending(result);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
return;
|
||||
@@ -148,7 +159,13 @@ const handlePasswordReset = async (): Promise<void> => {
|
||||
|
||||
<section id="login_card" class="login-card">
|
||||
<h3>로그인</h3>
|
||||
<form id="main_form" @submit.prevent="handleLogin">
|
||||
<DeletionRecoveryPanel
|
||||
v-if="deletionPending"
|
||||
:recovery="deletionPending"
|
||||
@restored="handleOtpVerified"
|
||||
@cancel="deletionPending = null"
|
||||
/>
|
||||
<form v-else id="main_form" @submit.prevent="handleLogin">
|
||||
<label for="username">계정명</label>
|
||||
<input
|
||||
id="username"
|
||||
@@ -214,6 +231,7 @@ const handlePasswordReset = async (): Promise<void> => {
|
||||
v-if="otpChallenge"
|
||||
:challenge-id="otpChallenge.challengeId"
|
||||
@verified="handleOtpVerified"
|
||||
@deletion-pending="handleDeletionPending"
|
||||
@cancel="otpChallenge = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,18 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
|
||||
import DeletionRecoveryPanel, { type DeletionPending } from '../components/DeletionRecoveryPanel.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { sealPassword } from '../utils/passwordEnvelope';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const deletionPending = ref<DeletionPending | null>(null);
|
||||
const handleDeletionPending = (recovery: DeletionPending): void => {
|
||||
otpChallenge.value = null;
|
||||
deletionPending.value = recovery;
|
||||
};
|
||||
const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const infoMessage = ref('');
|
||||
@@ -43,6 +49,10 @@ const completeExchange = async (): Promise<void> => {
|
||||
}
|
||||
try {
|
||||
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
|
||||
if (result.status === 'deletion_pending') {
|
||||
handleDeletionPending(result);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = result.successStatus;
|
||||
@@ -92,6 +102,10 @@ 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;
|
||||
@@ -133,6 +147,10 @@ const setMigratedPassword = async (): Promise<void> => {
|
||||
password.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordSetupSessionId.value = '';
|
||||
if (result.status === 'deletion_pending') {
|
||||
handleDeletionPending(result);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
|
||||
@@ -169,6 +187,10 @@ const register = async (): Promise<void> => {
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: thirdPartyUse.value,
|
||||
});
|
||||
if (result.status === 'deletion_pending') {
|
||||
handleDeletionPending(result);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = result.successStatus;
|
||||
@@ -202,15 +224,23 @@ onMounted(() => {
|
||||
<section class="oauth-card">
|
||||
<h2>
|
||||
{{
|
||||
accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
deletionPending
|
||||
? '탈퇴 취소'
|
||||
: accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||
<DeletionRecoveryPanel
|
||||
v-else-if="deletionPending"
|
||||
:recovery="deletionPending"
|
||||
@restored="handleOtpVerified"
|
||||
@cancel="router.replace('/')"
|
||||
/>
|
||||
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||
<p v-if="accountRecovery.action === 'link_existing'">
|
||||
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 이 계정에
|
||||
@@ -329,7 +359,9 @@ onMounted(() => {
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="errorMessage" class="oauth-error" role="alert">{{ errorMessage }}</p>
|
||||
<RouterLink v-if="!loading && !oauthSessionId" class="back-link" to="/">돌아가기</RouterLink>
|
||||
<RouterLink v-if="!loading && !oauthSessionId && !deletionPending" class="back-link" to="/"
|
||||
>돌아가기</RouterLink
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
</DefaultLayout>
|
||||
@@ -337,6 +369,7 @@ onMounted(() => {
|
||||
v-if="otpChallenge"
|
||||
:challenge-id="otpChallenge.challengeId"
|
||||
@verified="handleOtpVerified"
|
||||
@deletion-pending="handleDeletionPending"
|
||||
@cancel="otpChallenge = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user