feat: 탈퇴 취소와 이관 계정 복구 예외를 추가한다

This commit is contained in:
Hide_D committed 2026-11-07 00:24:57 +09:00
1 parent f16d33e34c
commit 257a2bea10
21 files changed
+730 -109

No files matched your search

@@ -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>
+19 -1
View File
@@ -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>